#Requires -Version 5.1
[CmdletBinding()]
param(
[string] $NodePath
)
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
function Resolve-NodePath {
param([AllowEmptyString()][string] $ExplicitPath)
if (-not [string]::IsNullOrWhiteSpace($ExplicitPath)) {
if (-not [IO.Path]::IsPathRooted($ExplicitPath) -or
-not (Test-Path -LiteralPath $ExplicitPath -PathType Leaf)) {
throw 'NodePath must identify an existing absolute node executable path.'
}
return [IO.Path]::GetFullPath($ExplicitPath)
}
$command = Get-Command node -CommandType Application -ErrorAction SilentlyContinue |
Select-Object -First 1
if ($null -ne $command) {
return $command.Source
}
$programFiles = [Environment]::GetFolderPath(
[Environment+SpecialFolder]::ProgramFiles)
$candidates = @(
(Join-Path $programFiles 'Microsoft Visual Studio\18\Community\MSBuild\Microsoft\VisualStudio\NodeJs\node.exe'),
(Join-Path $programFiles 'Microsoft Visual Studio\2022\Community\MSBuild\Microsoft\VisualStudio\NodeJs\node.exe')
)
foreach ($candidate in $candidates) {
if (Test-Path -LiteralPath $candidate -PathType Leaf) {
return [IO.Path]::GetFullPath($candidate)
}
}
throw 'A Node.js executable was not found. Pass its absolute path with -NodePath.'
}
$node = Resolve-NodePath -ExplicitPath $NodePath
$repositoryRoot = [IO.Path]::GetFullPath((Join-Path $PSScriptRoot '..'))
$scripts = @(
(Join-Path $repositoryRoot 'Web\playout-safety.js'),
(Join-Path $repositoryRoot 'Web\market-nav-reorder.js'),
(Join-Path $repositoryRoot 'Web\operator-workflow.js'),
(Join-Path $repositoryRoot 'Web\legacy-fixed-workflow.js'),
(Join-Path $repositoryRoot 'Web\industry-workflow.js'),
(Join-Path $repositoryRoot 'Web\comparison-workflow.js'),
(Join-Path $repositoryRoot 'Web\comparison-import-workflow.js'),
(Join-Path $repositoryRoot 'Web\theme-workflow.js'),
(Join-Path $repositoryRoot 'Web\overseas-workflow.js'),
(Join-Path $repositoryRoot 'Web\expert-workflow.js'),
(Join-Path $repositoryRoot 'Web\trading-halt-workflow.js'),
(Join-Path $repositoryRoot 'Web\candle-options-workflow.js'),
(Join-Path $repositoryRoot 'Web\manual-lists-workflow.js'),
(Join-Path $repositoryRoot 'Web\manual-financial-workflow.js'),
(Join-Path $repositoryRoot 'Web\named-manual-restore-workflow.js'),
(Join-Path $repositoryRoot 'Web\named-comparison-restore-workflow.js'),
(Join-Path $repositoryRoot 'Web\legacy-foreign-index-candle-workflow.js'),
(Join-Path $repositoryRoot 'Web\named-nxt-theme-restore-workflow.js'),
(Join-Path $repositoryRoot 'Web\legacy-named-row-workflow.js'),
(Join-Path $repositoryRoot 'Web\operator-catalog-workflow.js'),
(Join-Path $repositoryRoot 'Web\manual-lists-ui.js'),
(Join-Path $repositoryRoot 'Web\manual-financial-ui.js'),
(Join-Path $repositoryRoot 'Web\operator-catalog-ui.js'),
(Join-Path $repositoryRoot 'Web\named-playlist-workflow.js'),
(Join-Path $repositoryRoot 'Web\app.js')
)
$testFiles = @(
Get-ChildItem -LiteralPath (Join-Path $repositoryRoot 'tests\Web') -Filter '*.test.cjs' -File |
Sort-Object -Property Name |
Select-Object -ExpandProperty FullName
)
if ($testFiles.Count -eq 0) {
throw 'No Web playout test files were found.'
}
foreach ($script in $scripts) {
& $node --check $script
if ($LASTEXITCODE -ne 0) {
throw "JavaScript syntax validation failed with exit code $LASTEXITCODE."
}
}
$appScript = Get-Content -LiteralPath (Join-Path $repositoryRoot 'Web\app.js') -Raw -Encoding UTF8
$indexMarkup = Get-Content -LiteralPath (Join-Path $repositoryRoot 'Web\index.html') -Raw -Encoding UTF8
$namedPlaylistScript = Get-Content -LiteralPath (Join-Path $repositoryRoot 'Web\named-playlist-workflow.js') -Raw -Encoding UTF8
$mainWindowScript = Get-Content -LiteralPath (Join-Path $repositoryRoot 'MainWindow.xaml.cs') -Raw -Encoding UTF8
$namedPlaylistBridgeScript = Get-Content -LiteralPath (Join-Path $repositoryRoot 'MainWindow.NamedPlaylists.cs') -Raw -Encoding UTF8
$pagePlanBridgeScript = Get-Content -LiteralPath (Join-Path $repositoryRoot 'MainWindow.PlaylistPagePlans.cs') -Raw -Encoding UTF8
$legacyWorkflowScript = Get-Content -LiteralPath (Join-Path $repositoryRoot 'src\MBN_STOCK_WEBVIEW.Core\Playout\Scenes\LegacyPlayoutWorkflow.cs') -Raw -Encoding UTF8
$backgroundScript = Get-Content -LiteralPath (Join-Path $repositoryRoot 'MainWindow.Background.cs') -Raw -Encoding UTF8
$playoutMainScript = Get-Content -LiteralPath (Join-Path $repositoryRoot 'MainWindow.Playout.cs') -Raw -Encoding UTF8
$sceneCatalogMatches = [regex]::Matches(
$appScript,
'builderKey:\s*"s[0-9]+"')
if ($sceneCatalogMatches.Count -ne 35) {
throw "Web scene catalog must contain exactly 35 builder entries; found $($sceneCatalogMatches.Count)."
}
$selectionPresetMatches = [regex]::Matches(
$appScript,
'(?m)^\s+s[0-9]+:\s+\[')
if ($selectionPresetMatches.Count -ne 34 -or
$appScript -notmatch 'Every reachable legacy builder must have a Web selection preset') {
throw "Web scene selection presets must cover exactly 34 reachable builders; found $($selectionPresetMatches.Count)."
}
if ($appScript -notmatch 'reachableAliases\.size !== 45' -or
$appScript -notmatch 'sceneCutAlias' -or
$appScript -notmatch 'sceneAliases\(definition\)\.includes\(storedCode\)' -or
$appScript -notmatch 'resolveStoredCatalogIndex') {
throw 'Web scene selection must expose and preserve all 45 active cut aliases.'
}
if ($appScript -match 'payload\.cue' -or
$appScript -notmatch 'payload\.playlist' -or
$appScript -notmatch 'payload\.selectedIndex') {
throw 'Web playout must send a PREPARE playlist snapshot and must not choose the NEXT cue.'
}
if ($appScript -notmatch 'const DEFAULT_FADE_DURATION = 6;' -or
$appScript -notmatch 'fadeDuration: DEFAULT_FADE_DURATION') {
throw 'Web playout default fade must match MainForm ComboDi.SelectedIndex 6.'
}
if ($appScript -notmatch 'normalizeScenePreviewFields' -or
$appScript -notmatch 'currentPageItemCount' -or
$appScript -notmatch 'renderSceneDataPreview') {
throw 'Web playout must render bounded authoritative scene data and complete page state.'
}
if ($appScript -notmatch 'checkbox\.checked = item\.enabled !== false' -or
$appScript -notmatch 'item\.enabled = checkbox\.checked' -or
$appScript -notmatch 'isPlaylistSnapshotLocked' -or
$appScript -notmatch 'pending: Boolean\(state\.playout\.pending\)' -or
$appScript -notmatch 'outcomeUnknown: state\.playout\.sessionQuarantined') {
throw 'Web playlist must expose the legacy active flag and freeze its native snapshot.'
}
if ($appScript -notmatch 'toggleAllEntriesEnabled' -or
$appScript -notmatch 'deleteAllPlaylistEntries' -or
$appScript -notmatch 'event\?\.shiftKey' -or
$appScript -notmatch 'event\.key === "Home"' -or
$appScript -notmatch 'event\.key === "End"') {
throw 'Legacy playlist enabled-all, delete-all, range, Home and End controls are incomplete.'
}
if ($appScript -notmatch 'postNative\("playout-timeout-quarantine", \{ requestId, command \}\)' -or
$appScript -notmatch 'browserCorrelationQuarantined') {
throw 'Web response timeouts must latch the process-lifetime native quarantine.'
}
if ($appScript -match 'event\.key === "F2"\) requestPlayoutCommand' -or
$appScript -notmatch 'notifyLegacyBackgroundShortcut' -or
$appScript -notmatch 'legacyTakeInShortcutAction' -or
$appScript -notmatch 'synchronizeStockCandleOptions' -or
$appScript -notmatch 'operatorInputBuilderKeys\.has\(definition\.builderKey\)') {
throw 'Legacy F2, F8, moving-average and typed-playlist safety integration is incomplete.'
}
if ($indexMarkup -notmatch 'id="globalMa5"' -or
$indexMarkup -notmatch 'id="globalMa20"' -or
$indexMarkup -notmatch '' -or
$appScript -notmatch 'MbnCandleOptionsWorkflow' -or
$appScript -notmatch 'synchronizeGlobalCandleOptions' -or
$appScript -notmatch 'legacy-overseas-workflow' -or
$appScript -notmatch 'legacy-trading-halt-workflow') {
throw 'The original global 5-day/20-day candle options are not synchronized across every candle workflow.'
}
foreach ($asset in @(
'market-nav-reorder.js', 'manual-lists-workflow.js', 'manual-financial-workflow.js', 'named-manual-restore-workflow.js', 'legacy-foreign-index-candle-workflow.js', 'named-nxt-theme-restore-workflow.js', 'legacy-named-row-workflow.js', 'operator-catalog-workflow.js',
'manual-lists-ui.js', 'manual-financial-ui.js', 'operator-catalog-ui.js')) {
if ($indexMarkup -notmatch [regex]::Escape("")) {
throw "Operator UI script is not loaded: $asset"
}
}
if ($appScript -notmatch 'MbnMarketNavReorder' -or
$appScript -notmatch [regex]::Escape('marketNavReorder.createController') -or
$appScript -notmatch [regex]::Escape('hasOpenOperatorModal() || isPlaylistSnapshotLocked()')) {
throw 'The original business-tab drag and keyboard reorder contract is not connected.'
}
foreach ($asset in @('manual-lists-ui.css', 'manual-financial-ui.css', 'operator-catalog-ui.css')) {
if ($indexMarkup -notmatch [regex]::Escape("") ) {
throw "Operator UI stylesheet is not loaded: $asset"
}
}
if ($indexMarkup -notmatch 'id="themeCatalogManageButton"' -or
$indexMarkup -notmatch 'id="expertCatalogManageButton"' -or
$appScript -notmatch 'MbnManualListsUi' -or
$appScript -notmatch 'MbnManualFinancialUi' -or
$appScript -notmatch 'MbnOperatorCatalogUi' -or
$appScript -notmatch 'initializeOperatorUiControllers' -or
$appScript -notmatch 'appendTrustedOperatorEntry' -or
$appScript -notmatch 'manualFinancialPrepareBlocked' -or
$appScript -notmatch 'createRestoreSelectionRequest' -or
$appScript -notmatch 'operatorCatalogUi\?\.loseCorrelation') {
throw 'GraphE, FSell, VIList, ThemeA or EList is not fully connected to the Web operator surface.'
}
if ($appScript -notmatch 'requestBackgroundSelection' -or
$appScript -notmatch 'event\.key === "F3"' -or
$appScript -notmatch 'postNative\("request-background-status"' -or
$mainWindowScript -notmatch 'case "choose-background"' -or
$mainWindowScript -notmatch 'case "toggle-background"' -or
$playoutMainScript -notmatch 'MutableLegacySceneCueCompositionOptionsSource' -or
$backgroundScript -notmatch 'CreateOperatorSelection' -or
$backgroundScript -notmatch 'CreateLegacyDefault' -or
$backgroundScript -notmatch 'TryGetTrustedBackgroundDirectory') {
throw 'Legacy F2/F3 native background selection integration is incomplete.'
}
if ($appScript -notmatch 'restoreFixedPlaylistEntry' -or
$appScript -notmatch 'synchronizeFixedEntries' -or
$appScript -notmatch 'renderFixedWorkflow' -or
$appScript -notmatch 'legacy-fixed-workflow') {
throw 'Legacy fixed operator catalog integration is incomplete.'
}
if ($appScript -notmatch 'restoreIndustryPlaylistEntry' -or
$appScript -notmatch 'synchronizeIndustryEntries' -or
$appScript -notmatch 'renderIndustryWorkflow' -or
$appScript -notmatch 'postNative\("request-industries", \{ requestId, market \}\)' -or
$mainWindowScript -notmatch 'case "request-industries"' -or
$mainWindowScript -notmatch 'PostMessage\("industry-results"') {
throw 'Legacy KOSPI/KOSDAQ industry selector integration is incomplete.'
}
if ($appScript -notmatch 'restoreComparisonPlaylistEntry' -or
$appScript -notmatch 'synchronizeComparisonEntries' -or
$appScript -notmatch 'renderComparisonWorkflow' -or
$appScript -notmatch 'postNative\("search-world-stocks"' -or
$mainWindowScript -notmatch 'case "search-world-stocks"' -or
$mainWindowScript -notmatch 'PostMessage\("world-stock-search-results"') {
throw 'Legacy comparison selector and world-stock search integration is incomplete.'
}
if ($appScript -notmatch 'restoreThemePlaylistEntry' -or
$appScript -notmatch 'synchronizeThemeEntries' -or
$appScript -notmatch 'renderThemeWorkflow' -or
$appScript -notmatch 'postNative\("search-themes"' -or
$appScript -notmatch 'postNative\("request-theme-preview"' -or
$mainWindowScript -notmatch 'case "search-themes"' -or
$mainWindowScript -notmatch 'case "request-theme-preview"') {
throw 'Legacy read-only theme selector integration is incomplete.'
}
if ($appScript -notmatch 'restoreOverseasPlaylistEntry' -or
$appScript -notmatch 'synchronizeOverseasEntries' -or
$appScript -notmatch 'renderOverseasWorkflow' -or
$appScript -notmatch 'postNative\(overseasWorkflow\.bridgeContracts\[kind\]\.requestType, request\)' -or
$mainWindowScript -notmatch 'case "search-overseas-industries"' -or
$mainWindowScript -notmatch 'PostMessage\("overseas-industry-results"') {
throw 'Legacy overseas-industry and overseas-stock selector integration is incomplete.'
}
if ($appScript -notmatch 'restoreExpertPlaylistEntry' -or
$appScript -notmatch 'synchronizeExpertEntries' -or
$appScript -notmatch 'renderExpertWorkflow' -or
$appScript -notmatch 'postNative\(expertWorkflow\.bridgeContract\.previewRequestType, request\)' -or
$mainWindowScript -notmatch 'case "search-experts"' -or
$mainWindowScript -notmatch 'case "request-expert-preview"') {
throw 'Legacy read-only expert selector integration is incomplete.'
}
if ($appScript -notmatch 'restoreTradingHaltPlaylistEntry' -or
$appScript -notmatch 'synchronizeTradingHaltEntries' -or
$appScript -notmatch 'renderTradingHaltWorkflow' -or
$appScript -notmatch 'postNative\(tradingHaltWorkflow\.bridgeContract\.requestType, request\)' -or
$mainWindowScript -notmatch 'case "search-trading-halts"' -or
$mainWindowScript -notmatch 'PostMessage\("trading-halt-results"') {
throw 'Legacy trading-halt selector integration is incomplete.'
}
if ($indexMarkup -notmatch 'id="namedPlaylistPanel"' -or
$indexMarkup -notmatch 'id="namedPlaylistDefinitions"' -or
$indexMarkup -notmatch 'id="namedPlaylistCreateButton"' -or
$indexMarkup -notmatch 'id="namedPlaylistReplaceButton"' -or
$indexMarkup -notmatch 'id="namedPlaylistDeleteButton"' -or
$indexMarkup -notmatch 'id="namedPlaylistPageRetryButton"' -or
$indexMarkup -notmatch '' -or
$indexMarkup -notmatch 'id="loadButton"' -or
$indexMarkup -notmatch 'id="saveButton"' -or
$indexMarkup -notmatch 'id="namedPlaylistOpenButton"') {
throw 'Named Oracle playlist controls must remain explicit and separate from local temporary storage.'
}
if ($appScript -notmatch 'MbnNamedPlaylistWorkflow' -or
$appScript -notmatch 'requestNamedPlaylistList' -or
$appScript -notmatch 'requestNamedPlaylistLoad' -or
$appScript -notmatch 'requestNamedPlaylistCreate' -or
$appScript -notmatch 'requestNamedPlaylistReplace' -or
$appScript -notmatch 'requestNamedPlaylistDelete' -or
$appScript -notmatch 'namedPlaylistPrepareBlockers' -or
$appScript -notmatch 'restoreRawRows\([\s\S]*load,[\s\S]*resolveNamedPlaylistRawRow,[\s\S]*legacyNamedRowWorkflow\.matchesCanonicalEntry' -or
$appScript -notmatch 'requestNamedPlaylistPagePlans' -or
$appScript -notmatch 'normalizePagePlanResponse\(payload, pending\.request\)' -or
$appScript -notmatch 'page-result-empty' -or
$appScript -notmatch 'namedPlaylistBackupStorageKey' -or
$appScript -notmatch 'writeQuarantined') {
throw 'Named Oracle playlist UI, trusted restoration, backup, PREPARE guard, or write quarantine is incomplete.'
}
if ($namedPlaylistScript -notmatch 'request-playlist-page-plans' -or
$namedPlaylistScript -notmatch 'playlist-page-plans-results' -or
$namedPlaylistScript -notmatch 'playlist-page-plans-error' -or
$mainWindowScript -notmatch 'case "request-playlist-page-plans"' -or
$pagePlanBridgeScript -notmatch 'PreflightPagePlansAsync' -or
$pagePlanBridgeScript -notmatch 'await _databaseActivityGate\.WaitAsync\(requestToken\)' -or
$pagePlanBridgeScript -notmatch 'Volatile\.Read\(ref _playoutCommandInFlight\) != 0' -or
$legacyWorkflowScript -notmatch 'PreflightPagePlansAsync') {
throw 'Read-only named-playlist page preflight, exact Web correlation, or playout-priority gating is incomplete.'
}
foreach ($requestType in @(
'request-named-playlist-list',
'request-named-playlist-load',
'create-named-playlist',
'replace-named-playlist',
'delete-named-playlist')) {
if ($namedPlaylistScript -notmatch [regex]::Escape($requestType) -or
$mainWindowScript -notmatch [regex]::Escape($requestType)) {
throw "Named playlist request contract is incomplete: $requestType"
}
}
foreach ($responseType in @(
'named-playlist-list-results',
'named-playlist-load-results',
'named-playlist-mutation-results',
'named-playlist-error')) {
if ($appScript -notmatch [regex]::Escape($responseType) -or
$namedPlaylistBridgeScript -notmatch [regex]::Escape($responseType)) {
throw "Named playlist response contract is incomplete: $responseType"
}
}
if ($mainWindowScript -notmatch 'OnNavigationStarting' -or
$mainWindowScript -notmatch 'OnProcessFailed' -or
$mainWindowScript -notmatch 'ReloadBrowserSafely' -or
$mainWindowScript -notmatch 'QuarantineIfBrowserCorrelationCanBeLost' -or
$mainWindowScript -notmatch 'Volatile\.Read\(ref _playoutCommandInFlight\)') {
throw 'Reload, navigation, and WebView process failure must quarantine in-flight playout correlation.'
}
$cutCoverageFixturePath = Join-Path $PSScriptRoot '..\tests\Scripts\Test-LegacyCutCoverage.Fixture.ps1'
$cutCoverageFixture = & $cutCoverageFixturePath
if ($cutCoverageFixture.Status -ne 'Passed') {
throw 'Legacy cut and builder asset coverage fixtures failed.'
}
& $node --test @testFiles
if ($LASTEXITCODE -ne 0) {
throw "Web playout tests failed with exit code $LASTEXITCODE."
}
[pscustomobject][ordered]@{
Status = 'Passed'
SyntaxFiles = $scripts.Count
SceneCatalogEntries = $sceneCatalogMatches.Count
ReachableSelectionPresets = $selectionPresetMatches.Count
TestFiles = $testFiles.Count
CutCoverageFixture = $cutCoverageFixture.Status
}