diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index fbc7fbf37..38032079d 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -5,6 +5,8 @@ on: [push] env: DOTNET_CLI_TELEMETRY_OPTOUT: 1 DOTNET_SKIP_FIRST_TIME_EXPERIENCE: 1 + POWERSHELL_TELEMETRY_OPTOUT: 1 + POWERSHELL_UPDATECHECK: Off jobs: windows: @@ -24,6 +26,11 @@ jobs: with: fetch-depth: 0 + - name: Verify Prebuild Script (Pester Tests) + shell: powershell + run: | + Invoke-Pester -Path "UI/tests/prebuild_windows.test.ps1" -Passthru | ForEach-Object { if ($_.FailedCount -gt 0) { exit 1 } } + - name: Install .NET Core uses: actions/setup-dotnet@v4 with: diff --git a/COMPILING.md b/COMPILING.md index ec0a4b41a..2dd177c4f 100644 --- a/COMPILING.md +++ b/COMPILING.md @@ -1,9 +1,24 @@ ## Windows 1) Open the solution in Visual Studio 2022 + * Mesen uses the "Desktop Development With C++" workload and the ".NET Desktop Development" workload + * You do **not** need to install them in advance. If you don't have them, Visual Studio will install them when you open the project. 2) Compile as `Release`/`x64` 3) Set the startup project to the `UI` project and run +### Testing + +There is a [pester](https://pester.dev/) powershell test script in `UI/tests` that will verify the prebuild steps for the `UI` project. + + +1. [Install pester](https://pester.dev/docs/introduction/installation) if needed. +1. Run from any powershell console: + +```cmd +$ .\UI\tests\prebuild_windows.test.ps1 +``` + + ## Linux To build under Linux you need a version of Clang or GCC that supports C++17. diff --git a/UI/UI.csproj b/UI/UI.csproj index 9d5835a0f..9f00b5626 100644 --- a/UI/UI.csproj +++ b/UI/UI.csproj @@ -650,7 +650,9 @@ - + diff --git a/UI/prebuild_windows.ps1 b/UI/prebuild_windows.ps1 new file mode 100644 index 000000000..1678eb258 --- /dev/null +++ b/UI/prebuild_windows.ps1 @@ -0,0 +1,128 @@ +# find and copy the files needed for building the UI project. +# called by the build target in UI.csproj. +# these steps are stored as a script for easier reading and editing. + +param ( + [string]$OutDir, # $(ProjectDir)..\bin\$(RuntimeIdentifier)\$(Configuration), like bin\win-x64\Release + [string]$ProjectDir, + [string]$RuntimeIdentifier # win-x64, win-arm64, etc. +) + +$ErrorActionPreference = "Stop" + +try { + + $MissingParams = foreach ($Name in "ProjectDir", "OutDir", "RuntimeIdentifier") { + if ([string]::IsNullOrWhiteSpace((Get-Variable $Name -ValueOnly))) { + $Name + } + } + + if ($MissingParams) { + $List = $MissingParams -join ", " + throw [System.ArgumentException] "Required parameter(s) '[$List]' are missing or empty." + } + + # clean paths (PowerShell handles trailing slashes/dots automatically with Get-Item) + $ProjectDir = $ProjectDir.TrimEnd('\') + if ($OutDir -match '^[a-zA-Z]:') { + $FullOutDir = $OutDir + } else { + $FullOutDir = Join-Path "$ProjectDir" $OutDir + } + + Write-Host "[PREBUILD] Project Dir: $ProjectDir" + Write-Host "[PREBUILD] Target Dir : $FullOutDir" + + # ensure FullOutDir exists and move there + if (!(Test-Path $FullOutDir)) { New-Item -ItemType Directory -Path $FullOutDir | Out-Null } + if (!(Test-Path $FullOutDir)) { + throw [System.IO.DirectoryNotFoundException] "$FullOutDir does not exist" + } + Set-Location $FullOutDir + + # set up Dependencies folder + $DepsFolder = Join-Path $FullOutDir "Dependencies" + if (Test-Path $DepsFolder) { Remove-Item -Recurse -Force $DepsFolder } + New-Item -ItemType Directory -Path $DepsFolder | Out-Null + + # 1. copy external dlls managed by NuGet package manager. + # in web CI builds these are installed by 'dotnet restore'. + $Libs = @("libHarfBuzzSharp.dll", "libSkiaSharp.dll") + $NuGetBase = "$env:USERPROFILE\.nuget\packages" + $SpecificPathPart = "runtimes\$RuntimeIdentifier\native" + + foreach ($Lib in $Libs) { + $FoundFile = Get-ChildItem -Path $NuGetBase -Filter $Lib -Recurse | + Where-Object { $_.FullName -like "*$SpecificPathPart*" } | + Select-Object -First 1 + + if ($FoundFile -and (Test-Path $FoundFile.FullName)) { + $LibSourcePath = $FoundFile.FullName + Write-Host "[PREBUILD] Copying NuGet dll $LibSourcePath" + Copy-Item $LibSourcePath -Destination $DepsFolder + } else { + $troubleshooting = "Please rebuild or restore packages. (Right-click solution in Visual Studio -> click 'Restore NuGet Packages', or run 'dotnet restore' in shell" + + $SearchPattern = Join-Path $NuGetBase "*\$SpecificPathPart" + $DllSourceFolder = $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($SearchPattern) + + throw [System.IO.FileNotFoundException] "Required file '$Lib' for $RuntimeIdentifier not found in '$DllSourceFolder'. $troubleshooting" + } + } + + # 2. copy MesenCore.dll output from building the Core c++ project + $Dll = "MesenCore.dll" + $DllSourcePath = Join-Path $FullOutDir $Dll + + if (!(Test-Path $DllSourcePath)) { + Write-Host "[PREBUILD] $Dll not found in $DllSourcePath . Checking fallback path" + # Fallback: Check if the file is one level up or if the OutDir had a double-slash issue + $ParentDir = Split-Path $FullOutDir -Parent + $FoundFile = Get-ChildItem -Path $ParentDir -Filter $Dll -Recurse | Select-Object -First 1 + + if ($FoundFile) { + $DllSourcePath = $FoundFile.FullName + } + } + + if ($DllSourcePath -and (Test-Path $DllSourcePath)) { + Write-Host "[PREBUILD] Copying $Dll from: $DllSourcePath" + Copy-Item $DllSourcePath -Destination $DepsFolder + } else { + $ParentPath = Split-Path $DllSourcePath -Parent + $DllSourceFolder = $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($ParentPath) + throw [System.IO.FileNotFoundException] "Required file '$Dll' not found in '$DllSourceFolder'. Please try rebuilding the solution." + } + + # 3. copy files that exist in source control + Write-Host "[PREBUILD] Copying other dependencies from $ProjectDir\Dependencies" + Copy-Item -Path "$ProjectDir\Dependencies\*" -Destination $DepsFolder -Recurse -Force + + # zip files + $ZipPath = Join-Path $FullOutDir "Dependencies.zip" + if (Test-Path $ZipPath) { Remove-Item $ZipPath } + + Write-Host "[PREBUILD] Creating Zip $ZipPath..." + Compress-Archive -Path $DepsFolder -DestinationPath $ZipPath -Force + + # move instead of copy so we don't leave copies of the zip lying around + Move-Item $ZipPath -Destination (Join-Path $ProjectDir "Dependencies.zip") -Force + Write-Host "[PREBUILD] Success." +} +catch { + + # format an error message that shows the error inside Visual Studio. + # using this specific format turns this into a clickable link in the Error List window or tab. + # Format: path\to\file.ps1(line): error: message + # https://learn.microsoft.com/en-us/visualstudio/msbuild/msbuild-diagnostic-format-for-tasks + + $file = $_.InvocationInfo.ScriptName + $line = $_.InvocationInfo.ScriptLineNumber + $errorMessage = "$file($line): error: $($_.Exception.Message)" + + # write to regular output so Visual Studio picks up + parses the message + Write-Host $errorMessage + + exit 1 +} diff --git a/UI/tests/prebuild_windows.test.ps1 b/UI/tests/prebuild_windows.test.ps1 new file mode 100644 index 000000000..f3f550be2 --- /dev/null +++ b/UI/tests/prebuild_windows.test.ps1 @@ -0,0 +1,158 @@ +# test that all needed scenarios of the prebuild_windows script work. + +Describe "Mesen Prebuild Logic Verification" { + BeforeAll { + # set up sandbox in Temp + $Sandbox = Join-Path $env:TEMP "Mesen_CI_Sandbox" + if (Test-Path $Sandbox) { Remove-Item $Sandbox -Recurse -Force -ErrorAction SilentlyContinue } + New-Item -Path $Sandbox -ItemType Directory -Force | Out-Null + + # map T: to this sandbox so Join-Path ..\.. works correctly + if (Get-PSDrive T -ErrorAction SilentlyContinue) { Remove-PSDrive T } + New-PSDrive -Name T -PSProvider FileSystem -Root $Sandbox | Out-Null + + $script:OldProfile = $env:USERPROFILE + $env:USERPROFILE = "T:\Users\testuser" + + $PREBUILD_SCRIPT = "$PSScriptRoot/../prebuild_windows.ps1" + $RUNTIME_ID = "win-x64" + + $script:TestNumber = 0 + } + + AfterAll { + $env:USERPROFILE = $script:OldProfile + if (Get-PSDrive T -ErrorAction SilentlyContinue) { Remove-PSDrive T } + } + + Context "Full Mock Isolation" { + BeforeEach { + # mock all actions that might cause changes on disk + Mock Compress-Archive { } + Mock Copy-Item { } + Mock Get-Item { param($Path) return [PSCustomObject]@{ FullName = $Path } } + Mock Move-Item { } + Mock New-Item { return [PSCustomObject]@{ FullName = "MockedDir" } } + Mock Remove-Item { } + Mock Set-Location { } + Mock Test-Path { return $true } + + Mock Get-ChildItem { + param($Path, $Filter) + if ($Path -like "*\.nuget\*") { + return [PSCustomObject]@{ FullName = Join-Path $env:USERPROFILE ".nuget\packages\runtimes\$RUNTIME_ID\native\$Filter" } + } + if ($Filter -eq "MesenCore.dll") { + return [PSCustomObject]@{ FullName = Join-Path $env:USERPROFILE "code\mesen\bin\$RUNTIME_ID\Release\MesenCore.dll" } + } + } + $error.Clear() + + $script:TestNumber += 1 + Write-Host "-------" + Write-Host "[Test] $TestNumber" -ForegroundColor Cyan + } + + It "Should handle Relative OutDir (Local Style)" { + $params = @{ + ProjectDir = Join-Path $env:USERPROFILE "code\UI" + OutDir = "..\bin\Release" + RuntimeIdentifier = $RUNTIME_ID + } + { & $PREBUILD_SCRIPT @params } | Should -Not -Throw + } + + It "Should handle Absolute OutDir (CI Style)" { + $params = @{ + ProjectDir = Join-Path $env:USERPROFILE "code\UI" + OutDir = Join-Path $env:USERPROFILE "code\bin\Release" + RuntimeIdentifier = $RUNTIME_ID + } + { & $PREBUILD_SCRIPT @params } | Should -Not -Throw + } + + It "Matches the vcxproj OutDir structure" { + $Repo = "T:\MesenRepo" + $ProjectDir = "$Repo\UI" + + Mock Test-Path { return $true } + Mock Copy-Item { } + Mock Compress-Archive { } + Mock Move-Item { } + Mock New-Item { [PSCustomObject]@{ FullName = "MockedDir" } } + Mock Set-Location { } + + $params = @{ + ProjectDir = $ProjectDir + OutDir = "..\bin\$RUNTIME_ID\Release" + RuntimeIdentifier = $RUNTIME_ID + } + + { & $PREBUILD_SCRIPT @params } | Should -Not -Throw + } + + It "Should verify the zip ends up in the ProjectDir" { + $ProjectDir = Join-Path $env:USERPROFILE "code\mesen\UI" + + $params = @{ + ProjectDir = $ProjectDir + OutDir = "T:\Some\Other\Path" + RuntimeIdentifier = $RUNTIME_ID + } + { & $PREBUILD_SCRIPT @params } | Should -Not -Throw + + $ExpectedDestination = Join-Path $ProjectDir "Dependencies.zip" + + Assert-MockCalled Move-Item -ParameterFilter { + $Destination -eq $ExpectedDestination + } + } + + It "Should raise exception on empty paths" { + $params = @{ ProjectDir = ""; OutDir = ""; RuntimeIdentifier = "win-x64" } + + & $PREBUILD_SCRIPT @params 2>&1 + + $error.Count | Should -BeGreaterThan 0 + $error[0].Exception.GetType().FullName | Should -Be "System.ArgumentException" + $LASTEXITCODE | Should -Be 1 + } + + It "Should raise exception when NuGet DLL is missing" { + Mock Get-ChildItem { return $null } -ParameterFilter { $Path -like "*\.nuget\*" } + + $params = @{ + ProjectDir = "T:\Mesen" + OutDir = "bin" + RuntimeIdentifier = $RUNTIME_ID + } + + & $PREBUILD_SCRIPT @params 2>&1 + + $error.Count | Should -BeGreaterThan 0 + $error[0].Exception.GetType().FullName | Should -Be "System.IO.FileNotFoundException" + $LASTEXITCODE | Should -Be 1 + } + + It "Should print error when MesenCore.dll is missing" { + Mock Test-Path { + param($Path) + if ($Path -like "*MesenCore.dll") { return $false } + return $true + } + Mock Get-ChildItem { return $null } -ParameterFilter { $Filter -eq "MesenCore.dll" } + + $params = @{ + ProjectDir = "T:\Mesen" + OutDir = "bin" + RuntimeIdentifier = $RUNTIME_ID + } + + & $PREBUILD_SCRIPT @params 2>&1 + + $error.Count | Should -BeGreaterThan 0 + $error[0].Exception.GetType().FullName | Should -Be "System.IO.FileNotFoundException" + $LASTEXITCODE | Should -Be 1 + } + } +}