#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\named-foreign-stock-restore-workflow.js'), (Join-Path $repositoryRoot 'Web\legacy-foreign-index-candle-workflow.js'), (Join-Path $repositoryRoot 'Web\named-krx-theme-restore-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 'replacePlaylistEntryEnabledAt\(index, checkbox\.checked\)' -or $appScript -match 'item\.enabled\s*=(?!=)' -or $appScript -notmatch 'namedKrxThemeRestoreWorkflow\.withEnabled' -or $appScript -notmatch 'namedNxtThemeRestoreWorkflow\.withEnabled' -or $appScript -notmatch 'namedForeignStockRestoreWorkflow\.withEnabled' -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 -match '\bitem\.[A-Za-z_$][A-Za-z0-9_$]*\s*=(?!=)' -or $appScript -notmatch 'playlistEntryManualEditBlockReason' -or $appScript -notmatch 'recreatePlaylistEntryWithSceneSelection' -or $appScript -notmatch 'recreatePlaylistEntryWithLiveSelection' -or $appScript -notmatch 'replacePlaylistEntryAt\(state\.currentIndex, replacement\)') { throw 'Playlist scene and live-row edits must use immutable replacement and protect branded entries.' } 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', 'named-foreign-stock-restore-workflow.js', 'legacy-foreign-index-candle-workflow.js', 'named-krx-theme-restore-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 }