diff --git a/.github/workflows/build-package.yml b/.github/workflows/build-package.yml index 8fe9ec1f9..3f1f19755 100644 --- a/.github/workflows/build-package.yml +++ b/.github/workflows/build-package.yml @@ -344,10 +344,143 @@ jobs: if-no-files-found: warn overwrite: true + # Decides whether a PR needs the architecture matrix: it builds and launches 45 real apps, so docs-only + # and unrelated PRs skip it. Pushes to main and manual runs always run it. + project-arch-changes: + runs-on: ubuntu-latest + outputs: + run: ${{ steps.check.outputs.run }} + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 2 + + - name: Check for project-mode changes + id: check + shell: pwsh + env: + IS_PR: ${{ github.event_name == 'pull_request' }} + run: | + if ($env:IS_PR -ne 'true') { + "run=true" >> $env:GITHUB_OUTPUT + return + } + # The PR checkout is a merge commit; its first parent is the base branch. + $changed = @(git diff --name-only HEAD^1 HEAD) + if ($LASTEXITCODE -ne 0) { throw 'Could not list the files this PR changes.' } + $patterns = @( + '^src/winapp-CLI/WinApp\.Cli/Services/(ProjectRunService|ProjectDetectionService|DotNetService|PackageRegistrationService|AppLauncherService|MsixService)' + '^src/winapp-CLI/WinApp\.Cli/Commands/RunCommand' + '^src/winapp-CLI/WinApp\.Cli/Models/ProjectRun' + '^src/winapp-CLI/WinApp\.Cli/Helpers/(RunArchHelper|MsBuildPropertyReader)' + '^tests/project-arch/' + '^\.github/workflows/build-package\.yml$' + '^global\.json$' + ) + $relevant = @($changed | Where-Object { $file = $_; @($patterns | Where-Object { $file -match $_ }).Count -gt 0 }) + Write-Host "Project-mode changes: $(if ($relevant) { $relevant -join ', ' } else { 'none' })" + "run=$(($relevant.Count -gt 0).ToString().ToLowerInvariant())" >> $env:GITHUB_OUTPUT + + # Generated .NET projects covering how `winapp run` builds apps with project references and packaging. + # Fixtures run one at a time per shard; see tests/project-arch/README.md. + project-arch: + needs: [build-artifacts, project-arch-changes] + if: needs.project-arch-changes.outputs.run == 'true' + strategy: + fail-fast: false + matrix: + shard: [1, 2, 3, 4] + name: project-arch (${{ matrix.shard }}/4) + runs-on: windows-latest + timeout-minutes: 75 + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Enable Windows Developer Mode + run: | + reg add "HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\Windows\CurrentVersion\AppModelUnlock" /t REG_DWORD /v "AllowDevelopmentWithoutDevLicense" /d 1 /f + + # Some fixtures target net8.0 and must launch on its runtime. + - name: Setup .NET + uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 + with: + dotnet-version: | + 8.0.x + 10.0.x + + - name: Download CLI binaries + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: cli-binaries + path: artifacts/cli + + # The -x86 fixtures launch framework-dependent x86 apps. setup-dotnet sets DOTNET_ROOT to the x64 install, + # which an x86 app host would also use, so point DOTNET_ROOT_X86 at x86 runtimes (installed if missing). + - name: Prepare x86 .NET runtimes + run: | + $dir = Join-Path ${env:ProgramFiles(x86)} 'dotnet' + $installer = Join-Path $env:RUNNER_TEMP 'dotnet-install.ps1' + foreach ($runtime in @(@{ Name = 'dotnet'; Folder = 'Microsoft.NETCore.App' }, @{ Name = 'windowsdesktop'; Folder = 'Microsoft.WindowsDesktop.App' })) { + foreach ($channel in '8.0', '10.0') { + $present = @(Get-ChildItem (Join-Path $dir "shared\$($runtime.Folder)") -Directory -Filter "$channel.*" -ErrorAction SilentlyContinue) + $names = if ($present) { $present.Name -join ', ' } else { 'none' } + Write-Host "x86 $($runtime.Folder) $channel present: $names" + if ($present.Count -eq 0) { + if (-not (Test-Path $installer)) { Invoke-WebRequest https://dot.net/v1/dotnet-install.ps1 -OutFile $installer } + $install = @{ Channel = $channel; Runtime = $runtime.Name; Architecture = 'x86'; InstallDir = $dir } + & $installer @install + } + } + } + "DOTNET_ROOT_X86=$dir" >> $env:GITHUB_ENV + + - name: Run architecture matrix + env: + SHARD: ${{ matrix.shard }} + run: | + $pester = Get-Module -Name Pester -ListAvailable | Where-Object { $_.Version.Major -ge 5 } | Sort-Object Version -Descending | Select-Object -First 1 + if (-not $pester) { throw 'Pester 5 is required.' } + Import-Module $pester -Force + $container = New-PesterContainer -Path tests/project-arch/ProjectArch.Tests.ps1 -Data @{ + WinappPath = 'artifacts/cli/win-x64' + Architecture = 'x64' + Shard = "$env:SHARD/4" + DumpDirectory = 'artifacts/TestResults/project-arch-dumps' + } + $config = New-PesterConfiguration + $config.Run.Container = $container + $config.Run.Exit = $true + $config.Output.Verbosity = 'Detailed' + $config.TestResult.Enabled = $true + $config.TestResult.OutputPath = "artifacts/TestResults/project-arch-$env:SHARD.xml" + $config.TestResult.OutputFormat = 'JUnitXml' + Invoke-Pester -Configuration $config + + - name: Upload test results + if: ${{ !cancelled() }} + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: project-arch-results-${{ matrix.shard }} + path: artifacts/TestResults/project-arch-${{ matrix.shard }}.xml + if-no-files-found: error + overwrite: true + + - name: Upload hang dumps + if: failure() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: project-arch-dumps-${{ matrix.shard }} + path: artifacts/TestResults/project-arch-dumps + if-no-files-found: ignore + overwrite: true + retention-days: 7 + # Retain the original required check, but only turn it green after every lane. build-and-package: if: always() - needs: [build-artifacts, validate-tests, validate-docs, e2e-test-ui, samples, metrics] + needs: [build-artifacts, validate-tests, validate-docs, e2e-test-ui, samples, metrics, project-arch-changes, project-arch] runs-on: ubuntu-latest steps: - name: Require all build and validation jobs @@ -358,7 +491,9 @@ jobs: run: | $results = $env:NEEDS_JSON | ConvertFrom-Json -AsHashtable foreach ($job in $results.Keys) { - $expected = if ($job -eq 'samples' -and $env:IS_PR -ne 'true') { 'skipped' } else { 'success' } + $expected = if ($job -eq 'samples' -and $env:IS_PR -ne 'true') { 'skipped' } + elseif ($job -eq 'project-arch' -and $results['project-arch-changes'].outputs.run -ne 'true') { 'skipped' } + else { 'success' } if ($results[$job].result -ne $expected) { throw "$job did not meet the required outcome: $($results[$job].result) (expected $expected)" } diff --git a/AGENTS.md b/AGENTS.md index 94073efd8..7f32e72d8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -281,6 +281,17 @@ matrix with `npm-package` and `nuget-packages` from the same run. Manual sample runs still build their own packages and support selecting one sample. Sample results are uploaded as JUnit XML via Pester's `TestResult` configuration. +The `project-arch` job runs `tests\project-arch\ProjectArch.Tests.ps1` in four +shards with the same-run x64 CLI. It generates .NET projects covering how +`winapp run` builds them (architecture, reference graphs, packaging, Windows App +SDK versions) and launches each app. +It runs on every push to main and manual build, and on PRs whose changes match +the path list in the `project-arch-changes` job; `build-and-package` accepts it +as skipped only when that job says it isn't needed. Add a fixture there when +changing project-mode architecture handling, and extend the path list when that +handling moves to new files; see `tests\project-arch\README.md` to run it +locally. + Keep `build-and-package` and `test-samples-result` as top-level aggregate check names for branch protection. They must fail on failures, cancellations, and unexpected skips, not just test failures. Metrics join package artifacts with diff --git a/docs/guides/dotnet.md b/docs/guides/dotnet.md index 1f1bdda79..adc711e4a 100644 --- a/docs/guides/dotnet.md +++ b/docs/guides/dotnet.md @@ -207,7 +207,7 @@ winapp run .\bin\Debug\net10.0-windows10.0.26100.0 Project mode supports both **packaged** and **unpackaged** WinUI apps — it detects which from the project's `WindowsPackageType` and installs the matching-architecture Windows App Runtime automatically. To force an unpackaged run of a packaged project, add `-p WindowsPackageType=None`. -**Multi-project apps** (an app referencing class libraries) build correctly: winapp keeps `AnyCPU`/`netstandard2.0` references on their compatible platform instead of forcing the app's architecture across the graph. RID-only remains the default; when the effective configuration requires a self-contained profile (for example, a trimmed Release build), winapp selects the matching profile without changing referenced libraries' platforms. +**Multi-project apps** (an app referencing class libraries) build correctly: winapp keeps `AnyCPU`/`netstandard2.0` references on their compatible platform instead of forcing the app's architecture across the graph. RID-only remains the default; when the effective configuration requires a self-contained profile (for example, a trimmed Release build), winapp selects the matching profile without changing referenced libraries' platforms. The build also imports a small winapp MSBuild file (`-p:CustomAfterMicrosoftCommonTargets=…` in the printed command) so Windows App SDK packaging includes each referenced library once; it isn't added when your build sets `CustomAfterMicrosoftCommonTargets` itself. The `dotnet build` output streams live, with the exact invocation printed first. Add `--verbose` for winapp's own build decision traces. Requires .NET SDK 8.0.100 or newer. See [`winapp run` in the usage reference](../usage.md#project-mode-net-sdk-projects) for the full option list. diff --git a/scripts/tests/artifact-workflow.Tests.ps1 b/scripts/tests/artifact-workflow.Tests.ps1 index 513d92a20..fdd1521b4 100644 --- a/scripts/tests/artifact-workflow.Tests.ps1 +++ b/scripts/tests/artifact-workflow.Tests.ps1 @@ -71,7 +71,7 @@ Describe 'Artifact-first workflow dependencies' { $gate = Get-JobText $buildWorkflow 'build-and-package' $gate | Should -Match '(?m)^\s+if: always\(\)' - $gate | Should -Match 'needs: \[build-artifacts, validate-tests, validate-docs, e2e-test-ui, samples, metrics\]' + $gate | Should -Match 'needs: \[build-artifacts, validate-tests, validate-docs, e2e-test-ui, samples, metrics, project-arch-changes, project-arch\]' } It 'builds both architectures in one producer without intermediate publish artifacts' { @@ -93,7 +93,7 @@ Describe 'Artifact-first workflow dependencies' { $npmPackaging | Should -Match 'npm run compile --ignore-scripts' } - It 'starts validation, docs, UI E2E and samples from early artifacts, not the final gate' { + It 'starts validation, docs, UI E2E, samples and the architecture matrix from early artifacts, not the final gate' { foreach ($job in @('validate-tests', 'validate-docs', 'e2e-test-ui', 'samples')) { $text = Get-JobText $buildWorkflow $job $text | Should -Match '(?m)^\s+needs: build-artifacts\r?$' @@ -103,6 +103,17 @@ Describe 'Artifact-first workflow dependencies' { Should -Match ([regex]::Escape('$testArgs.CliShard = [int]$env:CLI_SHARD')) (Get-JobText $buildWorkflow 'e2e-test-ui') | Should -Match 'test-e2e-winui-ui\.ps1' (Get-JobText $buildWorkflow 'e2e-test-ui') | Should -Match 'test-ui-coordination\.ps1' + $matrix = Get-JobText $buildWorkflow 'project-arch' + $matrix | Should -Match '(?m)^\s+needs: \[build-artifacts, project-arch-changes\]\r?$' + $matrix | Should -Match ([regex]::Escape("if: needs.project-arch-changes.outputs.run == 'true'")) + $matrix | Should -Not -Match 'needs: build-and-package' + $matrix | Should -Match 'tests/project-arch/ProjectArch\.Tests\.ps1' + $matrix | Should -Match 'fail-fast: false' + $matrix | Should -Match 'timeout-minutes:' + $changes = Get-JobText $buildWorkflow 'project-arch-changes' + $changes | Should -Not -Match '(?m)^\s+needs:' + $changes | Should -Match 'fetch-depth: 2' + $changes | Should -Match "IS_PR -ne 'true'" } It 'runs both CLI shards, auxiliary suites once, and UI Automation on isolated runners' { @@ -170,7 +181,7 @@ Describe 'Partial reruns replace only owned artifacts' { Get-UploadSteps $sampleWorkflow Get-UploadSteps $reportAction ) - $uploads.Count | Should -Be 12 + $uploads.Count | Should -Be 14 foreach ($upload in $uploads) { $upload | Should -Match '(?m)^ overwrite: true\r?$' -Because $upload } @@ -189,6 +200,15 @@ Describe 'Partial reruns replace only owned artifacts' { foreach ($lane in @('Cli-1', 'Cli-2', 'Auxiliary', 'UIAutomation')) { $laneName.Replace('${{ matrix.lane }}', $lane) } + $shardUploads = @(Get-UploadSteps (Get-JobText $buildWorkflow 'project-arch')) + $shardUploads.Count | Should -Be 2 + $shards = [regex]::Match((Get-JobText $buildWorkflow 'project-arch'), '(?m)^ shard: \[(.+)\]').Groups[1].Value + foreach ($shardUpload in $shardUploads) { + $shardName = [regex]::Match($shardUpload, '(?m)^ name: (.+?)\r?$').Groups[1].Value + foreach ($shard in ($shards -split ', ')) { + $shardName.Replace('${{ matrix.shard }}', $shard) + } + } $sampleUpload = @(Get-UploadSteps (Get-JobText $sampleWorkflow 'test-sample')) $sampleUpload.Count | Should -Be 1 $sampleName = [regex]::Match($sampleUpload[0], '(?m)^ name: (.+?)\r?$').Groups[1].Value @@ -200,7 +220,7 @@ Describe 'Partial reruns replace only owned artifacts' { [regex]::Match($step, '(?m)^ name: (.+?)\r?$').Groups[1].Value } ) - $names.Count | Should -Be 27 + $names.Count | Should -Be 35 @($names | Where-Object { [string]::IsNullOrWhiteSpace($_) }).Count | Should -Be 0 @($names | Group-Object | Where-Object Count -GT 1).Count | Should -Be 0 (Get-JobText $sampleWorkflow 'build') | Should -Match 'if: \$\{\{ !inputs.use-existing-artifacts \}\}' @@ -247,14 +267,15 @@ Describe 'Failed validation reports without promoting the main baseline' { Describe 'Required build check outcomes' { BeforeEach { $script:results = @{} - foreach ($job in @('build-artifacts', 'validate-tests', 'validate-docs', 'e2e-test-ui', 'samples', 'metrics')) { + foreach ($job in @('build-artifacts', 'validate-tests', 'validate-docs', 'e2e-test-ui', 'samples', 'metrics', 'project-arch')) { $results[$job] = @{ result = 'success' } } + $results['project-arch-changes'] = @{ result = 'success'; outputs = @{ run = 'true' } } $env:IS_PR = 'true' } It 'passes a PR only when every dependency succeeds' { - $env:NEEDS_JSON = $results | ConvertTo-Json -Compress + $env:NEEDS_JSON = $results | ConvertTo-Json -Compress -Depth 4 { & $buildGate } | Should -Not -Throw } @@ -262,7 +283,7 @@ Describe 'Required build check outcomes' { foreach ($job in @($results.Keys)) { foreach ($outcome in @('failure', 'cancelled', 'skipped')) { $results[$job].result = $outcome - $env:NEEDS_JSON = $results | ConvertTo-Json -Compress + $env:NEEDS_JSON = $results | ConvertTo-Json -Compress -Depth 4 { & $buildGate } | Should -Throw -ExpectedMessage "*$job did not meet*" } $results[$job].result = 'success' @@ -272,12 +293,28 @@ Describe 'Required build check outcomes' { It 'allows only the deliberate sample skip on main and manual runs' { $env:IS_PR = 'false' $results.samples.result = 'skipped' - $env:NEEDS_JSON = $results | ConvertTo-Json -Compress + $env:NEEDS_JSON = $results | ConvertTo-Json -Compress -Depth 4 { & $buildGate } | Should -Not -Throw $results.'validate-tests'.result = 'skipped' - $env:NEEDS_JSON = $results | ConvertTo-Json -Compress + $env:NEEDS_JSON = $results | ConvertTo-Json -Compress -Depth 4 { & $buildGate } | Should -Throw } + + It 'requires the architecture matrix exactly when the change check asks for it' { + $results['project-arch-changes'].outputs.run = 'false' + $results['project-arch'].result = 'skipped' + $env:NEEDS_JSON = $results | ConvertTo-Json -Compress -Depth 4 + { & $buildGate } | Should -Not -Throw + + $results['project-arch'].result = 'success' + $env:NEEDS_JSON = $results | ConvertTo-Json -Compress -Depth 4 + { & $buildGate } | Should -Throw -ExpectedMessage '*project-arch did not meet*' + + $results['project-arch-changes'].outputs.run = 'true' + $results['project-arch'].result = 'skipped' + $env:NEEDS_JSON = $results | ConvertTo-Json -Compress -Depth 4 + { & $buildGate } | Should -Throw -ExpectedMessage '*project-arch did not meet*' + } } Describe 'Required sample check outcomes' { diff --git a/src/winapp-CLI/WinApp.Cli.Tests/ProjectRunServiceTests.cs b/src/winapp-CLI/WinApp.Cli.Tests/ProjectRunServiceTests.cs index aad39d08b..0a0f2c7da 100644 --- a/src/winapp-CLI/WinApp.Cli.Tests/ProjectRunServiceTests.cs +++ b/src/winapp-CLI/WinApp.Cli.Tests/ProjectRunServiceTests.cs @@ -412,6 +412,101 @@ public void BuildBuildPassArguments_Publish_UsesPublishVerbAndIncludesPublishIte Assert.IsFalse(buildArgs.Contains("IncludePublishItemsOutputGroup"), "build pass must not set the publish-items flag"); } + [TestMethod] + public void BuildBuildPassArguments_PackagingReferenceTargets_ImportedOnlyByTheBuildPass() + { + var csproj = new FileInfo(Path.Join(_tempDir.FullName, "App.csproj")); + var options = new ProjectRunOptions("Debug", "x64", null, NoBuild: false, NoRestore: false, Properties: [], + PackagingReferenceTargets: @"C:\cache\winapp-run.targets"); + + var buildArgs = ProjectRunService.BuildBuildPassArguments(csproj, options, "minimal"); + var publishArgs = ProjectRunService.BuildBuildPassArguments(csproj, options, "minimal", publish: true); + + StringAssert.Contains(buildArgs, @"-p:CustomAfterMicrosoftCommonTargets=C:\cache\winapp-run.targets"); + StringAssert.Contains(buildArgs, "-r win-x64", "the RID is still conveyed as before"); + Assert.IsFalse(publishArgs.Contains("CustomAfterMicrosoftCommonTargets", StringComparison.Ordinal), "pack's publish pass is unchanged"); + } + + [TestMethod] + public async Task BuildAndResolveAsync_RunBuild_ImportsPackagingReferenceTargets() + { + var csproj = WriteFile("App.csproj", ExecutableCsproj); + var dotnet = new FakeDotNetService { RunDotnetCommandHandler = _ => (0, PackagedPropertiesJson(), string.Empty) }; + var service = NewServiceWith(dotnet, LogLevel.Information, out _); + + await service.BuildAndResolveAsync(csproj, new ProjectRunOptions("Debug", "x64", null, NoBuild: false, NoRestore: false, Properties: []), CancellationToken.None); + + var build = dotnet.StreamingCalls.Single(a => a.StartsWith("build ", StringComparison.Ordinal)); + var match = System.Text.RegularExpressions.Regex.Match(build, "-p:CustomAfterMicrosoftCommonTargets=(\"[^\"]+\"|\\S+)"); + Assert.IsTrue(match.Success, build); + Assert.AreEqual(ProjectRunService.PackagingReferenceTargets, File.ReadAllText(match.Groups[1].Value.Trim('"'))); + } + + [TestMethod] + [DataRow("user-property", DisplayName = "-p CustomAfterMicrosoftCommonTargets on the command line")] + [DataRow("evaluated", DisplayName = "Set by the app's evaluation (project, import, environment)")] + [DataRow("reference", DisplayName = "Set in a referenced project")] + [DataRow("directory-build", DisplayName = "Set in a Directory.Build.props above a referenced project")] + [DataRow("evaluation-fails", DisplayName = "The app can't be evaluated")] + public async Task BuildAndResolveAsync_BuildSetsCustomAfterTargets_KeepsItsOwn(string where) + { + // A command-line value would replace the build's own, so winapp builds exactly as before. + const string setsIt = "custom.targets"; + var csproj = WriteFileAt(@"App\App.csproj", where is "reference" or "directory-build" + ? "WinExe" + : ExecutableCsproj); + WriteFileAt(@"Lib\Lib.csproj", where == "reference" ? setsIt : ""); + if (where == "directory-build") + { + WriteFileAt(@"Lib\Directory.Build.props", "custom.targets"); + } + + var dotnet = new FakeDotNetService + { + RunDotnetCommandHandler = args => args.Contains("--getProperty:CustomAfterMicrosoftCommonTargets", StringComparison.Ordinal) + ? where switch + { + "evaluated" => (0, PackagedPropertiesJson().Replace("\"Properties\": {", "\"Properties\": { \"CustomAfterMicrosoftCommonTargets\": \"C:\\\\repo\\\\eng\\\\after.targets\", \"MSBuildExtensionsPath\": \"C:\\\\sdk\", \"MSBuildToolsVersion\": \"Current\",", StringComparison.Ordinal), string.Empty), + "evaluation-fails" => (1, string.Empty, "error"), + _ => (0, PackagedPropertiesJson(), string.Empty), + } + : (0, PackagedPropertiesJson(), string.Empty), + }; + var service = NewServiceWith(dotnet, LogLevel.Information, out _); + string[] properties = where == "user-property" ? ["CustomAfterMicrosoftCommonTargets=custom.targets"] : []; + + await service.BuildAndResolveAsync(csproj, new ProjectRunOptions("Debug", "x64", null, NoBuild: false, NoRestore: false, Properties: properties), CancellationToken.None); + + var build = dotnet.StreamingCalls.Single(a => a.StartsWith("build ", StringComparison.Ordinal)); + Assert.IsFalse(build.Contains("winapp-run-", StringComparison.Ordinal), build); + } + + [TestMethod] + [DataRow("", true, DisplayName = "Not set")] + [DataRow(@"C:\sdk\vCurrent\Custom.After.Microsoft.Common.targets", true, DisplayName = "MSBuild's default path")] + [DataRow(@"C:\sdk\\vCurrent\Custom.After.Microsoft.Common.targets", true, DisplayName = "MSBuild's default path as evaluated (doubled separator)")] + [DataRow(@"C:\repo\eng\after.targets", false, DisplayName = "A project's own file")] + public void IsDefaultCustomAfterCommonTargets_RecognizesMSBuildsDefault(string value, bool expected) + { + var evaluated = new Dictionary + { + ["CustomAfterMicrosoftCommonTargets"] = value, + ["MSBuildExtensionsPath"] = @"C:\sdk\", + ["MSBuildToolsVersion"] = "Current", + }; + + Assert.AreEqual(expected, ProjectRunService.IsDefaultCustomAfterCommonTargets(evaluated)); + } + + [TestMethod] + public void WritePackagingReferenceTargets_WritesTheTargetsFile() + { + var path = ProjectRunService.WritePackagingReferenceTargets(); + + Assert.IsNotNull(path); + Assert.AreEqual(ProjectRunService.PackagingReferenceTargets, File.ReadAllText(path)); + } + [TestMethod] public void BuildNativeMsixPublishArguments_TriggersSdkPackagingAndCapturesOutput() { diff --git a/src/winapp-CLI/WinApp.Cli/Models/ProjectRunModels.cs b/src/winapp-CLI/WinApp.Cli/Models/ProjectRunModels.cs index 3f0a80855..2bb0eac67 100644 --- a/src/winapp-CLI/WinApp.Cli/Models/ProjectRunModels.cs +++ b/src/winapp-CLI/WinApp.Cli/Models/ProjectRunModels.cs @@ -121,6 +121,7 @@ internal sealed record ProjectRunResolution( /// Suppresses the injected -r win-<arch> because an effective Platform already conveys the architecture AND the ProjectReference closure splits on RuntimeIdentifier — a combination that otherwise builds the same project twice and fails a packaged build with APPX1101. A RESOLVED input (see ResolvePlatformInjection). /// The architecture-matching publish profile used when MSIX tooling requires a trimmed build to be self-contained, the profile preserves the project's trimming, target framework, and architecture, and forcing a global Platform would break an AnyCPU project reference. Inferred profiles are scoped to the selected app through the .NET SDK's ProjectToOverrideProjectExtensionsPath property. A RESOLVED input (see ResolveRequiredPublishProfileAsync), never user-supplied. /// An exact Windows RID (e.g. win-x64, win10-x64) that a lone -p RuntimeIdentifier supplied with no --arch. When set, it is forwarded to dotnet unchanged instead of the canonical win-<arch> derived from , so an exact/non-canonical RID is preserved (spec §4). is still derived from this RID for runtime provisioning. Null for every other caller, which conveys arch via the canonical RID. +/// The winapp MSBuild file the winapp run build pass imports through CustomAfterMicrosoftCommonTargets, so Windows App SDK packaging builds each referenced library once. A RESOLVED input (see ResolvePackagingReferenceTargetsAsync); null when the build already sets that property, and for publish passes. internal sealed record ProjectRunOptions( string Configuration, string Architecture, @@ -133,7 +134,8 @@ internal sealed record ProjectRunOptions( string? Platform = null, bool OmitRuntimeIdentifier = false, string? PublishProfile = null, - string? ExactRuntimeIdentifier = null) + string? ExactRuntimeIdentifier = null, + string? PackagingReferenceTargets = null) { /// /// The RID passed to dotnet: an explicit exact-RID override () diff --git a/src/winapp-CLI/WinApp.Cli/Services/ProjectRunService.Arguments.cs b/src/winapp-CLI/WinApp.Cli/Services/ProjectRunService.Arguments.cs index 00baaa41d..882f4041a 100644 --- a/src/winapp-CLI/WinApp.Cli/Services/ProjectRunService.Arguments.cs +++ b/src/winapp-CLI/WinApp.Cli/Services/ProjectRunService.Arguments.cs @@ -161,6 +161,11 @@ internal static string BuildBuildPassArguments(FileInfo csproj, ProjectRunOption AppendSolutionProperties(tokens, options); + if (!publish && !string.IsNullOrEmpty(options.PackagingReferenceTargets)) + { + tokens.Add($"-p:{CustomAfterCommonTargetsProperty}={EscapeMsBuildPropertyValue(options.PackagingReferenceTargets)}"); + } + // SHIM (temporary): inject the resolved ref-pack winmd folder so cswinrt.exe finds contract winmds // without a registered Windows SDK. See CsWinRTMetadataShimService. if (!string.IsNullOrEmpty(csWinRTMetadataFolder)) @@ -300,7 +305,8 @@ internal static string BuildEvaluateArguments( string? csWinRTMetadataFolder = null, bool includeRuntimeIdentifier = true, bool includePlatform = true, - bool includePublishProfile = true) + bool includePublishProfile = true, + IReadOnlyList? properties = null) { var rid = options.EffectiveRuntimeIdentifier; @@ -344,7 +350,7 @@ internal static string BuildEvaluateArguments( tokens.Add($"-p:CsWinRTWindowsMetadata={csWinRTMetadataFolder}"); } - foreach (var name in RequestedProperties) + foreach (var name in properties ?? RequestedProperties) { tokens.Add($"--getProperty:{name}"); } diff --git a/src/winapp-CLI/WinApp.Cli/Services/ProjectRunService.PackagingReferences.cs b/src/winapp-CLI/WinApp.Cli/Services/ProjectRunService.PackagingReferences.cs new file mode 100644 index 000000000..79a2bf1a5 --- /dev/null +++ b/src/winapp-CLI/WinApp.Cli/Services/ProjectRunService.PackagingReferences.cs @@ -0,0 +1,264 @@ +// Copyright (c) Microsoft Corporation and Contributors. All rights reserved. +// Licensed under the MIT License. + +using System.Security.Cryptography; +using System.Text; +using System.Xml.Linq; +using Microsoft.Extensions.Logging; +using WinApp.Cli.Helpers; +using WinApp.Cli.Models; + +namespace WinApp.Cli.Services; + +internal sealed partial class ProjectRunService +{ + private const string CustomAfterCommonTargetsProperty = "CustomAfterMicrosoftCommonTargets"; + + /// + /// MSBuild targets that winapp run imports into its build (through + /// CustomAfterMicrosoftCommonTargets) so Windows App SDK packaging sees project references the + /// way the rest of the build does. + /// + /// + /// + /// For a reference that doesn't depend on a runtime identifier (a typical class library), the .NET SDK + /// marks the prepared reference with UndefineProperties=RuntimeIdentifier, so the library builds + /// once, without the RID. The MSIX/PRI packaging targets query the raw ProjectReferenceWithConfiguration + /// items instead, which lack that marker, so they build the library a second time with the global + /// -r into a win-<arch> subfolder. Packaging then finds two copies (APPX1101) or looks + /// for a file that was never written (PRI175/PRI252, MSB3030). The first target hands the packaging + /// targets the prepared items, so both queries agree. + /// + /// + /// After a build failed that way, the app's cached PRI input list (pri.resfiles) still names the + /// missing file, and the PRI targets only regenerate it when their inputs change. The second target + /// deletes that list when it names a file that no longer exists. + /// + /// + /// Setting the property on the command line hides the machine-wide default file it normally names, so + /// that file is imported here when present. + /// + /// + internal const string PackagingReferenceTargets = """ + + + + + + + + + %(_MSBuildProjectReferenceExistent.UndefineProperties);%(_MSBuildProjectReferenceExistent.GlobalPropertiesToRemove) + + + + + + + + + + <_WinappMissingPriInput Include="@(_WinappPriInput)" Condition="!Exists('%(Identity)')" /> + + + + + """; + + private static readonly string[] CustomAfterProbeProperties = [CustomAfterCommonTargetsProperty, "MSBuildExtensionsPath", "MSBuildToolsVersion"]; + + /// + /// Returns the path of the file to import into a + /// winapp run build, writing it if needed, or to build exactly as before. + /// A command-line value would replace one the build already sets, so this returns + /// when the user passes the property, when the app's evaluated value (from its + /// project, imports, environment or response files) is anything but MSBuild's default, when a + /// referenced project or a Directory.Build.* above it names the property, and when the app + /// can't be evaluated. reuses an earlier evaluation of the app, when one ran. + /// + private async Task ResolvePackagingReferenceTargetsAsync( + FileInfo csproj, + ProjectRunOptions options, + IReadOnlyDictionary? evaluated, + DirectoryInfo workingDir, + string? csWinRTMetadataFolder, + CancellationToken cancellationToken) + { + if (UserSpecifiesProperty(options.Properties, CustomAfterCommonTargetsProperty)) + { + return null; + } + + evaluated ??= await TryEvaluateCustomAfterPropertiesAsync(csproj, options, workingDir, csWinRTMetadataFolder, cancellationToken); + if (evaluated is null) + { + return null; + } + + if (!IsDefaultCustomAfterCommonTargets(evaluated) || ProjectGraphMentionsProperty(csproj, CustomAfterCommonTargetsProperty)) + { + logger.LogDebug("{UISymbol} The build sets {Property}; not importing winapp's packaging-reference targets.", UiSymbols.Note, CustomAfterCommonTargetsProperty); + return null; + } + + return WritePackagingReferenceTargets(); + } + + private async Task?> TryEvaluateCustomAfterPropertiesAsync( + FileInfo csproj, + ProjectRunOptions options, + DirectoryInfo workingDir, + string? csWinRTMetadataFolder, + CancellationToken cancellationToken) + { + var args = BuildEvaluateArguments(csproj, options, csWinRTMetadataFolder, properties: CustomAfterProbeProperties); + logger.LogDebug("{UISymbol} dotnet {Arguments}", UiSymbols.Note, RedactSecretsForDisplay(args)); + try + { + var (exitCode, stdout, _) = await dotNetService.RunDotnetCommandAsync(workingDir, args, cancellationToken); + return exitCode == 0 ? MsBuildPropertyReader.Parse(stdout, CustomAfterProbeProperties) : null; + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch (Exception ex) when (ex is System.ComponentModel.Win32Exception or InvalidOperationException) + { + return null; + } + } + + /// + /// True when the evaluated CustomAfterMicrosoftCommonTargets is empty or MSBuild's default + /// machine-wide path, which imports itself. + /// + internal static bool IsDefaultCustomAfterCommonTargets(IReadOnlyDictionary evaluated) + { + var value = evaluated.TryGetValue(CustomAfterCommonTargetsProperty, out var v) ? v.Trim() : string.Empty; + if (value.Length == 0) + { + return true; + } + + var extensions = evaluated.TryGetValue("MSBuildExtensionsPath", out var e) ? e : string.Empty; + var toolsVersion = evaluated.TryGetValue("MSBuildToolsVersion", out var t) ? t : string.Empty; + if (extensions.Length == 0) + { + return false; + } + + // MSBuild concatenates the default as text ("…\sdk\10.0.401\\vCurrent\…"), so compare normalized paths. + try + { + var defaultPath = Path.GetFullPath(Path.Join(extensions, $"v{toolsVersion}", "Custom.After.Microsoft.Common.targets")); + return string.Equals(Path.GetFullPath(value), defaultPath, StringComparison.OrdinalIgnoreCase); + } + catch (Exception ex) when (ex is ArgumentException or NotSupportedException or PathTooLongException) + { + return false; + } + } + + /// Writes to a per-content temp path, or returns null. + internal static string? WritePackagingReferenceTargets() + { + var hash = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(PackagingReferenceTargets)))[..12]; + var path = Path.Join(Path.GetTempPath(), "winapp", "msbuild", $"winapp-run-{hash}.targets"); + try + { + if (!File.Exists(path)) + { + Directory.CreateDirectory(Path.GetDirectoryName(path)!); + AtomicFile.WriteAllText(path, PackagingReferenceTargets); + } + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + // Without the file the build behaves exactly as before, so a write failure isn't fatal. + return null; + } + + return path; + } + + /// + /// True when the project, a project it references (statically resolvable includes), or a + /// Directory.Build.props/Directory.Build.targets above any of them names + /// . Referenced projects aren't evaluated, so this catches the common + /// way a reference sets the property. + /// + internal static bool ProjectGraphMentionsProperty(FileInfo start, string propertyName) + { + var visitedProjects = new HashSet(StringComparer.OrdinalIgnoreCase) { start.FullName }; + var visitedDirectories = new HashSet(StringComparer.OrdinalIgnoreCase); + var queue = new Queue(); + queue.Enqueue(start); + + while (queue.Count > 0) + { + var project = queue.Dequeue(); + if (FileMentions(project.FullName, propertyName)) + { + return true; + } + + for (var directory = project.Directory; directory is not null && visitedDirectories.Add(directory.FullName); directory = directory.Parent) + { + if (FileMentions(Path.Join(directory.FullName, "Directory.Build.props"), propertyName) + || FileMentions(Path.Join(directory.FullName, "Directory.Build.targets"), propertyName)) + { + return true; + } + } + + var unvisitedReferences = ReadAllProjectReferenceIncludes(project) + .Select(include => TryResolveReferencePath(project, include, out var resolved) ? resolved : null) + .OfType() + .Where(reference => visitedProjects.Count < MaxProjectReferenceClosure && visitedProjects.Add(reference.FullName)); + foreach (var referenced in unvisitedReferences) + { + queue.Enqueue(referenced); + } + } + + return false; + + static bool FileMentions(string path, string text) + { + try + { + return File.Exists(path) && File.ReadAllText(path).Contains(text, StringComparison.OrdinalIgnoreCase); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + return false; + } + } + } + + /// Every ProjectReference include in the project XML, build-only references included. + private static List ReadAllProjectReferenceIncludes(FileInfo project) + { + XDocument doc; + try + { + doc = XDocument.Load(project.FullName); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or System.Xml.XmlException) + { + return []; + } + + return doc.Descendants() + .Where(e => e.Name.LocalName == "ProjectReference") + .Select(e => e.Attribute("Include")?.Value) + .Where(include => !string.IsNullOrWhiteSpace(include)) + .SelectMany(include => include!.Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) + .ToList(); + } +} diff --git a/src/winapp-CLI/WinApp.Cli/Services/ProjectRunService.PublishProfile.cs b/src/winapp-CLI/WinApp.Cli/Services/ProjectRunService.PublishProfile.cs index 75cf6ca3c..be48564ee 100644 --- a/src/winapp-CLI/WinApp.Cli/Services/ProjectRunService.PublishProfile.cs +++ b/src/winapp-CLI/WinApp.Cli/Services/ProjectRunService.PublishProfile.cs @@ -20,9 +20,10 @@ internal sealed partial class ProjectRunService /// framework-dependent build run the SDK's publish-time optimization validation. Plain SDK builds retain /// their configured deployment mode because they do not hit that validation during dotnet build. /// NativeAOT and already-self-contained projects also retain RID-only behavior. The profile sets Platform - /// locally in the app, so AnyCPU references keep their own Platform. + /// locally in the app, so AnyCPU references keep their own Platform. Also returns the app's evaluated + /// properties when this check evaluated it, so later decisions can reuse them. /// - private async Task ResolveRequiredPublishProfileAsync( + private async Task<(ProjectRunOptions Options, IReadOnlyDictionary? Evaluated)> ResolveRequiredPublishProfileAsync( FileInfo csproj, ProjectRunOptions options, DirectoryInfo workingDirectory, @@ -31,7 +32,7 @@ private async Task ResolveRequiredPublishProfileAsync( { if (!CanInferPublishProfile(options)) { - return options; + return (options, null); } var currentProperties = await TryEvaluatePublishProfilePropertiesAsync( @@ -45,7 +46,7 @@ private async Task ResolveRequiredPublishProfileAsync( || !RequiresSelfContainedProfile(currentProperties) || HasAuthoritativeProfileSelector(currentProperties)) { - return options; + return (options, currentProperties); } var platform = FindArchPlatformToken(csproj, options.Architecture) ?? options.Architecture; @@ -59,7 +60,7 @@ private async Task ResolveRequiredPublishProfileAsync( if (platformProperties is null || !TryGetImportedPublishProfileFile(csproj, platformProperties, out var profile)) { - return options; + return (options, currentProperties); } var candidateOptions = options with { PublishProfile = Path.GetFileName(profile.FullName) }; @@ -70,14 +71,14 @@ private async Task ResolveRequiredPublishProfileAsync( csWinRTMetadataFolder, cancellationToken); - return candidateProperties is null + return (candidateProperties is null ? options : ResolvePublishProfileFallback( csproj, options, currentProperties, platformProperties, - candidateProperties); + candidateProperties), currentProperties); } private async Task?> TryEvaluatePublishProfilePropertiesAsync( diff --git a/src/winapp-CLI/WinApp.Cli/Services/ProjectRunService.cs b/src/winapp-CLI/WinApp.Cli/Services/ProjectRunService.cs index 2cc2e372e..3618cd8c5 100644 --- a/src/winapp-CLI/WinApp.Cli/Services/ProjectRunService.cs +++ b/src/winapp-CLI/WinApp.Cli/Services/ProjectRunService.cs @@ -73,6 +73,10 @@ internal sealed partial class ProjectRunService( "WebPublishProfileFile", "PublishProfileImported", "_PublishProfileRootFolder", + // Whether the build already sets CustomAfterMicrosoftCommonTargets (see ResolvePackagingReferenceTargetsAsync). + "CustomAfterMicrosoftCommonTargets", + "MSBuildExtensionsPath", + "MSBuildToolsVersion", "TargetFramework", "Platform", "RuntimeIdentifier", @@ -226,15 +230,25 @@ private static bool UserSetCsWinRTMetadata(ProjectRunOptions options) => // arch, so it can't desync a no- reference (MSB3030/PRI252). Threaded into every pass // below (restore/build/evaluate) via `options`, keeping them in lock-step. options = ResolvePlatformInjection(csproj, options, requireConcreteRid: aotPublish); + IReadOnlyDictionary? evaluated = null; if (!aotPublish) { - options = await ResolveRequiredPublishProfileAsync( + (options, evaluated) = await ResolveRequiredPublishProfileAsync( csproj, options, workingDir, csWinRTMetadata, cancellationToken); } + + if (!publish && !aotPublish && !options.NoBuild) + { + options = options with + { + PackagingReferenceTargets = await ResolvePackagingReferenceTargetsAsync(csproj, options, evaluated, workingDir, csWinRTMetadata, cancellationToken), + }; + } + var buildOptions = options; // When the target lives in a solution, restore the whole solution's managed projects up front so @@ -718,7 +732,7 @@ private static ProjectRunOptions WithPublishContext(ProjectRunOptions options) = var shimFramework = await ResolveShimFrameworkAsync(csproj, options, workingDir, cancellationToken); var csWinRTMetadata = ResolveCsWinRTMetadataShim(options, shimFramework); options = ResolvePlatformInjection(csproj, options); - options = await ResolveRequiredPublishProfileAsync( + (options, _) = await ResolveRequiredPublishProfileAsync( csproj, options, workingDir, diff --git a/tests/project-arch/ProjectArch.Tests.ps1 b/tests/project-arch/ProjectArch.Tests.ps1 new file mode 100644 index 000000000..59fa4b8d8 --- /dev/null +++ b/tests/project-arch/ProjectArch.Tests.ps1 @@ -0,0 +1,164 @@ +#Requires -Version 7 +<# +.SYNOPSIS + Runs `winapp run` against generated .NET projects that cover how the target architecture reaches + MSBuild, and checks that each app builds, runs, and runs as the requested architecture. + +.DESCRIPTION + Each fixture is generated from ProjectArch.psm1 into -WorkRoot, run with the real CLI one at a time, + then unregistered and deleted. See README.md. + +.PARAMETER WinappPath + winapp.exe, or a folder containing it. Default: artifacts\cli\win-\winapp.exe, then winapp on PATH. + +.PARAMETER Architecture + Target architecture passed to --arch. Default: the host architecture. + +.PARAMETER Fixture + Wildcard patterns selecting fixture IDs. Default: all. + +.PARAMETER Shard + 'N/M' runs every M-th fixture starting at N (1-based), for splitting the suite across CI runners. The + failed-build recovery test runs only in the first shard. + +.PARAMETER DumpDirectory + When set, a winapp run that times out is dumped here (full memory) before it is stopped. +#> +param( + [string]$WinappPath, + [ValidateSet('x64', 'arm64', 'x86')] + [string]$Architecture = [Runtime.InteropServices.RuntimeInformation]::OSArchitecture.ToString().ToLowerInvariant(), + [string[]]$Fixture = @('*'), + [string]$Shard, + [string]$WorkRoot = (Join-Path ([IO.Path]::GetTempPath()) 'winapp-project-arch'), + [int]$TimeoutMinutes = 15, + [string]$DumpDirectory, + [switch]$SkipCleanup +) + +BeforeDiscovery { + Import-Module "$PSScriptRoot\ProjectArch.psm1" -Force + + $fixtures = @(Get-ProjectArchFixtures | Where-Object { + $id = $_.Id + @($Fixture | Where-Object { $id -like $_ }).Count -gt 0 + } | Sort-Object { $_.Id }) + + if ($Shard) { + if ($Shard -notmatch '^(\d+)/(\d+)$' -or [int]$Matches[1] -lt 1 -or [int]$Matches[1] -gt [int]$Matches[2]) { + throw "-Shard must be N/M with 1 <= N <= M (got '$Shard')." + } + $index = [int]$Matches[1] - 1 + $count = [int]$Matches[2] + $fixtures = @(for ($i = $index; $i -lt $fixtures.Count; $i += $count) { $fixtures[$i] }) + } + + $script:skip = -not $IsWindows -or -not (Get-Command dotnet -ErrorAction SilentlyContinue) +} + +BeforeAll { + Import-Module "$PSScriptRoot\ProjectArch.psm1" -Force + + $script:winapp = $null + if ($WinappPath) { + $script:winapp = if (Test-Path $WinappPath -PathType Container) { Join-Path $WinappPath 'winapp.exe' } else { $WinappPath } + } + else { + $hostArch = [Runtime.InteropServices.RuntimeInformation]::OSArchitecture.ToString().ToLowerInvariant() + $built = Join-Path $PSScriptRoot "..\..\artifacts\cli\win-$hostArch\winapp.exe" + $script:winapp = if (Test-Path $built) { (Resolve-Path $built).Path } else { (Get-Command winapp -ErrorAction SilentlyContinue)?.Source } + } + if (-not $script:winapp -or -not (Test-Path $script:winapp)) { + throw "winapp.exe not found. Pass -WinappPath, or build it with .\scripts\build-cli.ps1." + } + $script:winapp = (Resolve-Path $script:winapp).Path + Write-Host "Using $script:winapp for --arch $Architecture" + + New-Item -ItemType Directory -Path $WorkRoot -Force | Out-Null + + function Get-OutputTail([string]$Text, [int]$Lines = 60) { + (($Text -split "`r?`n") | Select-Object -Last $Lines) -join "`n" + } +} + +Describe 'winapp run architecture matrix' { + It ': ' -ForEach $fixtures -Skip:$script:skip { + $fixture = $_ + $arch = if ($fixture.ContainsKey('Arch')) { $fixture.Arch } else { $Architecture } + $root = Join-Path $WorkRoot $fixture.Id + $run = $null + try { + New-ProjectArchFixture -Fixture $fixture -Root $root + $run = Invoke-ProjectArchRun -Fixture $fixture -Root $root -Winapp $script:winapp -Architecture $arch -TimeoutMinutes $TimeoutMinutes -DumpDirectory $DumpDirectory + $tail = Get-OutputTail $run.Output + + $run.TimedOut | Should -BeFalse -Because "winapp run must finish within $TimeoutMinutes minutes:`n$tail" + $run.ExitCode | Should -Be 0 -Because "winapp run must succeed:`n$tail" + + if ($fixture.ContainsKey('UserCustomAfter') -and $fixture.UserCustomAfter) { + $run.Output | Should -Not -Match 'CustomAfterMicrosoftCommonTargets=' -Because "winapp must not replace the project's own CustomAfterMicrosoftCommonTargets" + Join-Path $root 'App\custom-after.marker' | Should -Exist -Because "the project's own CustomAfterMicrosoftCommonTargets must still be imported" + } + else { + $run.Output | Should -Match 'CustomAfterMicrosoftCommonTargets=' -Because 'winapp run imports its packaging-reference targets' + } + + $exe = $null + $windowed = $fixture.App.Kind -ne 'console' + if ($run.Launched -and $run.ProcessId) { + $process = Get-Process -Id $run.ProcessId -ErrorAction SilentlyContinue + if ($windowed) { + $process | Should -Not -BeNullOrEmpty -Because "the app must still be running after launch:`n$tail" + # Hold the handle so the exit code stays readable if the app exits. + $null = $process.Handle + $deadline = [DateTime]::UtcNow.AddSeconds(30) + while ([DateTime]::UtcNow -lt $deadline -and -not $process.HasExited -and $process.MainWindowHandle -eq [IntPtr]::Zero) { + Start-Sleep -Milliseconds 250 + $process.Refresh() + } + $exitNote = if ($process.HasExited) { "exit code 0x{0:X8}" -f $process.ExitCode } else { '' } + $process.HasExited | Should -BeFalse -Because "the app must survive startup ($exitNote)" + $process.MainWindowHandle | Should -Not -Be ([IntPtr]::Zero) -Because 'the app must show a window within 30 seconds' + # A missing runtime makes the app host show an error dialog instead of the app's window. + $class = Get-WindowClassName $process.MainWindowHandle + $class | Should -Not -Be '#32770' -Because "the window must be the app's, not an error dialog ('$($process.MainWindowTitle)')" + } + if ($process -and -not $process.HasExited) { $exe = $process.Path } + } + + if (-not $exe) { + # Not launched, or already exited (console): check the newest built executable. + $exe = Get-ChildItem (Join-Path $root 'App\bin') -Recurse -Filter 'FixtureApp.exe' -ErrorAction SilentlyContinue | + Sort-Object LastWriteTimeUtc -Descending | Select-Object -First 1 -ExpandProperty FullName + } + + $exe | Should -Not -BeNullOrEmpty -Because "winapp must produce FixtureApp.exe:`n$tail" + Get-PeArchitecture $exe | Should -Be $arch -Because "$exe must be built for --arch $arch" + } + finally { + if ($run) { Remove-ProjectArchRun -Fixture $fixture -Run $run } + if (-not $SkipCleanup -and (Test-Path $root)) { Remove-Item $root -Recurse -Force -ErrorAction SilentlyContinue } + } + } +} + +# A build that already failed with the duplicate-library error leaves a cached PRI input list naming the +# missing file. The next `winapp run` must recover without a clean. Runs once (first shard). +Describe 'winapp run after a failed build' -Skip:($script:skip -or ($Shard -and $Shard -notmatch '^1/')) { + It 'recovers from a stale PRI input list' { + $fixture = Get-ProjectArchFixtures | Where-Object Id -eq 'R14-msix-lib' + $root = Join-Path $WorkRoot 'recovery-R14' + try { + New-ProjectArchFixture -Fixture $fixture -Root $root + $failed = & dotnet build (Join-Path $root 'App\App.csproj') -c Debug -r "win-$Architecture" -v minimal -tl:off 2>&1 + $LASTEXITCODE | Should -Not -Be 0 -Because "a plain RID build of this graph fails with the duplicate-library error:`n$($failed | Select-Object -Last 20 | Out-String)" + + $run = Invoke-ProjectArchRun -Fixture $fixture -Root $root -Winapp $script:winapp -Architecture $Architecture -TimeoutMinutes $TimeoutMinutes -NoLaunch + $run.ExitCode | Should -Be 0 -Because "winapp run must recover without a clean:`n$(Get-OutputTail $run.Output)" + } + finally { + Remove-ProjectArchRun -Fixture $fixture -Run $null + if (-not $SkipCleanup -and (Test-Path $root)) { Remove-Item $root -Recurse -Force -ErrorAction SilentlyContinue } + } + } +} diff --git a/tests/project-arch/ProjectArch.psm1 b/tests/project-arch/ProjectArch.psm1 new file mode 100644 index 000000000..cd5e6f367 --- /dev/null +++ b/tests/project-arch/ProjectArch.psm1 @@ -0,0 +1,560 @@ +Set-StrictMode -Version Latest + +# Generates small .NET projects that cover how `winapp run` conveys the target architecture, and runs the +# real CLI against them. See README.md for the axes the fixtures cover. + +$script:AssetsSource = Join-Path $PSScriptRoot '..\..\samples\winui-app\Assets' + +$script:SdkMatrix = @{ + '1.6' = @{ WinAppSdk = '1.6.250205002'; BuildTools = '10.0.26100.1742'; Tfm = 'net8.0-windows10.0.22621.0' } + '1.8' = @{ WinAppSdk = '1.8.260317003'; BuildTools = '10.0.26100.7175'; Tfm = 'net10.0-windows10.0.26100.0' } + '2.5' = @{ WinAppSdk = '2.5.1'; BuildTools = '10.0.26100.7175'; Tfm = 'net10.0-windows10.0.26100.0' } +} + +$script:AllPlatforms = 'x86;x64;ARM64' +$script:AllRids = 'win-x86;win-x64;win-arm64' + +function New-AppSpec([hashtable]$Overrides = @{}) { + $app = @{ Kind = 'winui'; Packaged = $true; Platforms = $script:AllPlatforms; Rids = $script:AllRids; Refs = @() } + foreach ($key in $Overrides.Keys) { $app[$key] = $Overrides[$key] } + $app +} + +<# +.SYNOPSIS + Returns the fixture catalog. Each fixture is a hashtable: + Id, Sdk ('1.6'|'1.8'|'2.5'), Why (what it guards), App (kind/packaging/Platforms/RIDs/refs/extra + properties), Libs (referenced projects), and optional PlatformsInProps, Sln, PubXml, TargetsXml, Arch + (overrides the suite's -Architecture, for cross-architecture builds), and UserCustomAfter (the fixture + sets its own CustomAfterMicrosoftCommonTargets, which winapp must keep). +#> +function Get-ProjectArchFixtures { + $msixLib = @{ EnableMsixTooling = 'true' } + $fixtures = @( + # App shapes + @{ Id = 'A01-packaged'; Sdk = '1.8'; App = (New-AppSpec); Why = 'Packaged WinUI app' } + @{ Id = 'A01-packaged-wasdk16'; Sdk = '1.6'; App = (New-AppSpec); Why = 'Packaged WinUI app on WinAppSDK 1.6' } + @{ Id = 'A01-packaged-wasdk25'; Sdk = '2.5'; App = (New-AppSpec); Why = 'Packaged WinUI app on WinAppSDK 2.x' } + @{ Id = 'A02-no-platforms'; Sdk = '1.8'; App = (New-AppSpec @{ Platforms = $null }); Why = 'App without ' } + @{ Id = 'A02-no-platforms-wasdk16'; Sdk = '1.6'; App = (New-AppSpec @{ Platforms = $null }); Why = 'App without on WinAppSDK 1.6' } + @{ Id = 'A03-platforms-in-props'; Sdk = '1.8'; PlatformsInProps = $true; App = (New-AppSpec @{ Platforms = $null; Refs = @('LibW') }); Libs = @(@{ Name = 'LibW'; Kind = 'winui' }); Why = ' from Directory.Build.props' } + @{ Id = 'A04-wasdk-selfcontained'; Sdk = '1.8'; App = (New-AppSpec @{ SelfContained = $true }); Why = 'WindowsAppSDKSelfContained' } + @{ Id = 'A04-wasdk-selfcontained-16'; Sdk = '1.6'; App = (New-AppSpec @{ SelfContained = $true }); Why = 'WindowsAppSDKSelfContained on 1.6 (rejects Platform=AnyCPU)' } + @{ Id = 'A05-unpackaged'; Sdk = '1.8'; App = (New-AppSpec @{ Packaged = $false }); Why = 'Unpackaged WinUI app' } + @{ Id = 'A05-unpackaged-wasdk16'; Sdk = '1.6'; App = (New-AppSpec @{ Packaged = $false }); Why = 'Unpackaged WinUI app on 1.6' } + @{ Id = 'A06-wpf-plain-lib'; Sdk = '1.8'; App = (New-AppSpec @{ Kind = 'wpf'; Packaged = $false; Platforms = $null; Rids = $null; Refs = @('LibP') }); Libs = @(@{ Name = 'LibP'; Kind = 'plain' }); Why = 'WPF app without WinAppSDK' } + @{ Id = 'A07-wpf-wasdk'; Sdk = '1.8'; App = (New-AppSpec @{ Kind = 'wpf'; Packaged = $false; WinAppSdk = $true; Platforms = $null; Rids = $null }); Why = 'WPF app using WinAppSDK' } + @{ Id = 'A08-console-wasdk'; Sdk = '1.8'; App = (New-AppSpec @{ Kind = 'console'; Packaged = $false; WinAppSdk = $true; Platforms = $null; Rids = $null }); Why = 'Console app using WinAppSDK' } + + # Reference graphs + @{ Id = 'R01-plain-lib'; Sdk = '1.8'; App = (New-AppSpec @{ Refs = @('LibP') }); Libs = @(@{ Name = 'LibP'; Kind = 'plain' }); Why = 'netstandard2.0 reference' } + @{ Id = 'R02-winui-lib'; Sdk = '1.8'; App = (New-AppSpec @{ Refs = @('LibW') }); Libs = @(@{ Name = 'LibW'; Kind = 'winui' }); Why = 'WinUI library without ' } + @{ Id = 'R02-winui-lib-wasdk16'; Sdk = '1.6'; App = (New-AppSpec @{ Refs = @('LibW') }); Libs = @(@{ Name = 'LibW'; Kind = 'winui' }); Why = 'RID-agnostic WinUI library with MSIX tooling (1.6): built twice under a global RID' } + @{ Id = 'R02-winui-lib-wasdk25'; Sdk = '2.5'; App = (New-AppSpec @{ Refs = @('LibW') }); Libs = @(@{ Name = 'LibW'; Kind = 'winui' }); Why = 'WinUI library on 2.x' } + @{ Id = 'R03-winui-lib-platforms'; Sdk = '1.8'; App = (New-AppSpec @{ Refs = @('LibW') }); Libs = @(@{ Name = 'LibW'; Kind = 'winui'; Platforms = 'AnyCPU;x64;x86;ARM64' }); Why = 'WinUI library with ' } + @{ Id = 'R03-winui-lib-platforms-wasdk16'; Sdk = '1.6'; App = (New-AppSpec @{ Refs = @('LibW') }); Libs = @(@{ Name = 'LibW'; Kind = 'winui'; Platforms = 'AnyCPU;x64;x86;ARM64' }); Why = 'FluentStore shape: library with and MSIX tooling' } + @{ Id = 'R04-winui-lib-rids'; Sdk = '1.8'; App = (New-AppSpec @{ Refs = @('LibW') }); Libs = @(@{ Name = 'LibW'; Kind = 'winui'; Rids = $script:AllRids }); Why = 'WinUI library with ' } + @{ Id = 'R05-winui-lib-rids-platforms'; Sdk = '1.8'; App = (New-AppSpec @{ Refs = @('LibW') }); Libs = @(@{ Name = 'LibW'; Kind = 'winui'; Rids = $script:AllRids; Platforms = $script:AllPlatforms }); Why = 'WinUI library with both' } + @{ Id = 'R06-multitarget-lib'; Sdk = '1.8'; App = (New-AppSpec @{ Refs = @('LibM') }); Libs = @(@{ Name = 'LibM'; Kind = 'multi'; Platforms = 'AnyCPU;x64;x86;ARM64' }); Why = 'Multi-targeted reference' } + @{ Id = 'R07-diamond'; Sdk = '1.8'; App = (New-AppSpec @{ Refs = @('LibW', 'LibP') }); Libs = @(@{ Name = 'LibW'; Kind = 'winui'; Refs = @('LibP') }, @{ Name = 'LibP'; Kind = 'plain' }); Why = 'Diamond reference graph' } + @{ Id = 'R08-globalpropertiestoremove'; Sdk = '1.8'; App = (New-AppSpec @{ Refs = @(@{ Name = 'LibW'; Meta = @{ GlobalPropertiesToRemove = 'RuntimeIdentifier' } }) }); Libs = @(@{ Name = 'LibW'; Kind = 'winui'; Platforms = $script:AllPlatforms }); Why = 'Reference that removes RuntimeIdentifier' } + @{ Id = 'R09-anycpu-only-lib'; Sdk = '1.8'; App = (New-AppSpec @{ Refs = @('LibW') }); Libs = @(@{ Name = 'LibW'; Kind = 'winui'; Platforms = 'AnyCPU' }); Why = 'Library declaring only AnyCPU' } + @{ Id = 'R10-fluentstore'; Sdk = '1.6'; App = (New-AppSpec @{ Refs = @('LibW', 'LibR') }); Libs = @( + @{ Name = 'LibW'; Kind = 'winui'; Platforms = 'AnyCPU;x64;x86;ARM64'; Refs = @('LibP') }, + @{ Name = 'LibR'; Kind = 'winui'; Platforms = $script:AllPlatforms; Rids = $script:AllRids; Refs = @('LibP') }, + @{ Name = 'LibP'; Kind = 'multi'; Platforms = 'AnyCPU;x64;x86;ARM64' }); Why = 'FluentStore graph: mixed RID-agnostic and RID-specific WinUI libraries' } + @{ Id = 'R11-unpackaged-winui-lib'; Sdk = '1.8'; App = (New-AppSpec @{ Packaged = $false; Refs = @('LibW') }); Libs = @(@{ Name = 'LibW'; Kind = 'winui' }); Why = 'Unpackaged app with WinUI library' } + @{ Id = 'R12-solution'; Sdk = '1.8'; Sln = $true; App = (New-AppSpec @{ Refs = @('LibW') }); Libs = @(@{ Name = 'LibW'; Kind = 'winui' }); Why = 'App inside a solution' } + @{ Id = 'R13-selfcontained-winui-lib'; Sdk = '1.8'; App = (New-AppSpec @{ SelfContained = $true; Refs = @('LibW') }); Libs = @(@{ Name = 'LibW'; Kind = 'winui' }); Why = 'Self-contained app with WinUI library' } + @{ Id = 'R14-msix-lib'; Sdk = '1.8'; App = (New-AppSpec @{ Refs = @('LibW') }); Libs = @(@{ Name = 'LibW'; Kind = 'winui'; Extra = $msixLib }); Why = 'RID-agnostic library with MSIX tooling (1.8)' } + @{ Id = 'R14-msix-lib-platforms'; Sdk = '1.8'; App = (New-AppSpec @{ Refs = @('LibW') }); Libs = @(@{ Name = 'LibW'; Kind = 'winui'; Platforms = 'AnyCPU;x64;x86;ARM64'; Extra = $msixLib }); Why = 'Library with and MSIX tooling (1.8)' } + @{ Id = 'R14-msix-lib-wasdk25'; Sdk = '2.5'; App = (New-AppSpec @{ Refs = @('LibW') }); Libs = @(@{ Name = 'LibW'; Kind = 'winui'; Extra = $msixLib }); Why = 'Library with MSIX tooling (2.x)' } + @{ Id = 'R15-unpackaged-msix-lib'; Sdk = '1.8'; App = (New-AppSpec @{ Packaged = $false; Refs = @('LibW') }); Libs = @(@{ Name = 'LibW'; Kind = 'winui'; Extra = $msixLib }); Why = 'Unpackaged app with MSIX-tooling library' } + @{ Id = 'R15-unpackaged-msix-lib-wasdk16'; Sdk = '1.6'; App = (New-AppSpec @{ Packaged = $false; Refs = @('LibW') }); Libs = @(@{ Name = 'LibW'; Kind = 'winui' }); Why = 'Unpackaged app with MSIX-tooling library (1.6)' } + @{ Id = 'R16-dynamic-platform-resolution'; Sdk = '1.8'; App = (New-AppSpec @{ Refs = @('LibW'); Extra = @{ EnableDynamicPlatformResolution = 'true' } }); Libs = @(@{ Name = 'LibW'; Kind = 'winui'; Rids = $script:AllRids; Extra = @{ EnableDynamicPlatformResolution = 'true' } }); Why = 'EnableDynamicPlatformResolution' } + @{ Id = 'R17-edpr-rid-split'; Sdk = '1.8'; App = (New-AppSpec @{ Refs = @(@{ Name = 'LibW'; Meta = @{ GlobalPropertiesToRemove = 'RuntimeIdentifier' } }, 'LibP'); Extra = @{ EnableDynamicPlatformResolution = 'true' } }); Libs = @( + @{ Name = 'LibW'; Kind = 'winui'; Platforms = $script:AllPlatforms; Refs = @('LibP') }, + @{ Name = 'LibP'; Kind = 'plain'; Platforms = $script:AllPlatforms }); Why = 'EnableDynamicPlatformResolution with a reference that removes RuntimeIdentifier (must not get the RID back)' } + + # Project settings that interact with Platform/RID + @{ Id = 'P01-platforms-lack-arch'; Sdk = '1.8'; App = (New-AppSpec @{ Platforms = 'x86;x64' }); Why = ' without the target arch' } + @{ Id = 'P02-net-selfcontained'; Sdk = '1.8'; App = (New-AppSpec @{ Extra = @{ SelfContained = 'true' } }); Why = '.NET SelfContained without a RID' } + @{ Id = 'P03-publishaot-build'; Sdk = '1.8'; App = (New-AppSpec @{ Extra = @{ PublishAot = 'true' } }); Why = 'PublishAot project built without --aot' } + @{ Id = 'P04-hardcoded-rid'; Sdk = '1.8'; App = (New-AppSpec @{ Extra = @{ RuntimeIdentifier = 'win-x86' } }); Why = 'Hard-coded RuntimeIdentifier for another arch' } + @{ Id = 'P05-rid-from-platform'; Sdk = '1.8'; App = (New-AppSpec @{ Platforms = 'X86;X64;ARM64'; Extra = @{ RuntimeIdentifier = 'win-$(Platform)' } }); Why = 'RuntimeIdentifier=win-$(Platform) with upper-case platforms' } + @{ Id = 'P06-publish-profiles'; Sdk = '1.8'; PubXml = @{ Name = 'win-{0}.pubxml'; Template = $true }; App = (New-AppSpec @{ Refs = @('LibW'); Extra = @{ PublishProfile = 'win-$(Platform).pubxml' } }); Libs = @(@{ Name = 'LibW'; Kind = 'winui' }); Why = 'Visual Studio template publish profiles' } + @{ Id = 'P06-publish-profiles-wasdk16'; Sdk = '1.6'; PubXml = @{ Name = 'win10-{0}.pubxml'; Template = $true }; App = (New-AppSpec @{ Extra = @{ PublishProfile = 'win10-$(Platform).pubxml' } }); Why = 'WinAppSDK 1.6 template publish profiles' } + @{ Id = 'P07-unpackaged-selfcontained'; Sdk = '1.8'; App = (New-AppSpec @{ Packaged = $false; Extra = @{ SelfContained = 'true'; WindowsAppSDKSelfContained = 'true' } }); Why = 'Unpackaged fully self-contained app' } + @{ Id = 'P08-trimmed-profile-anycpu-lib'; Sdk = '1.8'; PubXml = @{ Name = 'debug-{0}.pubxml'; Template = $false }; App = (New-AppSpec @{ Refs = @('LibP'); Extra = @{ PublishTrimmed = 'true'; PublishProfile = 'debug-$(Platform).pubxml' } }); Libs = @(@{ Name = 'LibP'; Kind = 'plain' }); Why = 'Trimmed build whose $(Platform) profile makes it self-contained' } + + # Cross-architecture builds (Arch differs from x64 and arm64 hosts; x86 runs on both) + @{ Id = 'X01-generator-cross-arch'; Sdk = '1.8'; Arch = 'x86'; App = (New-AppSpec @{ Kind = 'console'; Packaged = $false; Platforms = $null; Rids = $null; Refs = @(@{ Name = 'Gen'; Meta = @{ OutputItemType = 'Analyzer'; ReferenceOutputAssembly = 'false' } }) }); Libs = @(@{ Name = 'Gen'; Kind = 'generator' }); Why = 'Project-referenced source generator built for another architecture (the compiler must still load it)' } + @{ Id = 'X02-generator-imported'; Sdk = '1.8'; Arch = 'x86'; App = (New-AppSpec @{ Kind = 'console'; Packaged = $false; Platforms = $null; Rids = $null }); Libs = @(@{ Name = 'Gen'; Kind = 'generator' }); TargetsXml = ''; Why = 'Generator reference added by an imported Directory.Build.targets, cross-architecture' } + @{ Id = 'X03-generator-macro-path'; Sdk = '1.8'; Arch = 'x86'; App = (New-AppSpec @{ Kind = 'console'; Packaged = $false; Platforms = $null; Rids = $null; Refs = @(@{ Name = 'Gen'; Include = '$(MSBuildThisFileDirectory)..\Gen\Gen.csproj'; Meta = @{ OutputItemType = 'Analyzer'; ReferenceOutputAssembly = 'false' } }) }); Libs = @(@{ Name = 'Gen'; Kind = 'generator' }); Why = 'Generator reference through a $(...) path, cross-architecture' } + @{ Id = 'X04-generator-property-path'; Sdk = '1.8'; Arch = 'x86'; App = (New-AppSpec @{ Kind = 'console'; Packaged = $false; Platforms = $null; Rids = $null; Extra = @{ GenFolder = 'Gen' }; Refs = @(@{ Name = 'Gen'; Include = '..\$(GenFolder)\$(GenFolder).csproj'; Meta = @{ OutputItemType = 'Analyzer'; ReferenceOutputAssembly = 'false' } }) }); Libs = @(@{ Name = 'Gen'; Kind = 'generator' }); Why = 'Generator reference through a project-defined property path, cross-architecture' } + ) + + # Settings on referenced projects (the decision must look past the app) + $fixtures += @( + @{ Id = 'R18-nested-edpr'; Sdk = '1.8'; App = (New-AppSpec @{ Refs = @('LibW') }); Libs = @(@{ Name = 'LibW'; Kind = 'winui'; Refs = @('LibP'); Extra = @{ EnableDynamicPlatformResolution = 'true' } }, @{ Name = 'LibP'; Kind = 'plain' }); Why = 'EnableDynamicPlatformResolution on a referenced project' } + @{ Id = 'R19-ref-hardcoded-rid'; Sdk = '1.8'; App = (New-AppSpec @{ Refs = @('LibW') }); Libs = @(@{ Name = 'LibW'; Kind = 'winui'; Extra = @{ RuntimeIdentifier = 'win-x86' } }); Why = 'Referenced project with a hard-coded RuntimeIdentifier for another arch' } + @{ Id = 'R20-ref-rid-from-platform'; Sdk = '1.8'; App = (New-AppSpec @{ Platforms = 'X86;X64;ARM64'; Refs = @('LibW') }); Libs = @(@{ Name = 'LibW'; Kind = 'winui'; Platforms = 'X86;X64;ARM64'; Extra = @{ RuntimeIdentifier = 'win-$(Platform)' } }); Why = 'Referenced project with RuntimeIdentifier=win-$(Platform) and upper-case platforms' } + @{ Id = 'R21-ref-exe'; Sdk = '1.8'; App = (New-AppSpec @{ Refs = @('Helper') }); Libs = @(@{ Name = 'Helper'; Kind = 'exe' }); Why = 'Referenced helper executable' } + @{ Id = 'R23-build-only-helper-rid'; Sdk = '1.8'; App = (New-AppSpec @{ Kind = 'console'; Packaged = $false; Platforms = $null; Rids = $null; Refs = @(@{ Name = 'Helper'; Meta = @{ ReferenceOutputAssembly = 'false' } }) }); Libs = @(@{ Name = 'Helper'; Kind = 'exe'; Extra = @{ RuntimeIdentifier = 'win-x86' } }); Why = 'Build-order-only helper reference (ReferenceOutputAssembly=false) with its own RuntimeIdentifier' } + @{ Id = 'R24-multitarget-tfm-rid'; Sdk = '1.8'; App = (New-AppSpec @{ Kind = 'console'; Packaged = $false; Platforms = $null; Rids = $null; Refs = @('LibM') }); Libs = @(@{ Name = 'LibM'; Kind = 'multi'; Xml = ' win-x86' }); Why = 'Multi-targeted reference that sets a RuntimeIdentifier for only one target framework' } + @{ Id = 'R25-helper-cross-arch'; Sdk = '1.6'; Arch = 'x86'; App = (New-AppSpec @{ Refs = @('LibW', @{ Name = 'Helper'; Meta = @{ ReferenceOutputAssembly = 'false' } }) }); Libs = @(@{ Name = 'LibW'; Kind = 'winui' }, @{ Name = 'Helper'; Kind = 'exe' }); Why = 'Packaged app with a WinUI library and a build-order-only helper, built for another architecture' } + @{ Id = 'R26-ref-setplatform-rid'; Sdk = '1.8'; App = (New-AppSpec @{ Kind = 'console'; Packaged = $false; Platforms = $null; Rids = $null; Refs = @(@{ Name = 'LibP'; Meta = @{ SetPlatform = 'Platform=AnyCPU' } }) }); Libs = @(@{ Name = 'LibP'; Kind = 'exe'; Xml = ' win-x86' }); Why = 'Reference whose SetPlatform metadata selects a RuntimeIdentifier' } + @{ Id = 'R27-user-customafter'; Sdk = '1.8'; UserCustomAfter = $true; App = (New-AppSpec @{ Refs = @('LibW') }); Libs = @(@{ Name = 'LibW'; Kind = 'winui' }); Why = 'Project that sets its own CustomAfterMicrosoftCommonTargets (winapp must keep it)' } + ) + + # The same shapes built cross-architecture: --arch x86 runs on both x64 and arm64 hosts. + $crossArch = 'A01-packaged', 'A01-packaged-wasdk16', 'A04-wasdk-selfcontained', 'A05-unpackaged', 'A07-wpf-wasdk', 'P06-publish-profiles', 'P08-trimmed-profile-anycpu-lib', 'R02-winui-lib-wasdk16', 'R10-fluentstore', 'R12-solution', 'R14-msix-lib', 'R21-ref-exe' + foreach ($fixture in @($fixtures | Where-Object { $_.Id -in $crossArch })) { + $copy = $fixture.Clone() + $copy.Id = "$($fixture.Id)-x86" + $copy.Arch = 'x86' + $copy.Why = "$($fixture.Why), built for x86" + $fixtures += $copy + } + + $fixtures +} + +function Write-FixtureFile([string]$Path, [string]$Text) { + $directory = Split-Path $Path + if (-not (Test-Path $directory)) { New-Item -ItemType Directory -Path $directory -Force | Out-Null } + [IO.File]::WriteAllText($Path, $Text) +} + +function Format-Properties($properties) { + ($properties.GetEnumerator() | Where-Object { $null -ne $_.Value } | + ForEach-Object { " <$($_.Name)>$($_.Value)" }) -join "`n" +} + +function Format-References($references) { + if (-not $references) { return '' } + $items = foreach ($reference in $references) { + if ($reference -is [string]) { $reference = @{ Name = $reference } } + $metadata = '' + if ($reference.ContainsKey('Meta')) { + $metadata = ($reference.Meta.GetEnumerator() | ForEach-Object { " $($_.Name)=`"$($_.Value)`"" }) -join '' + } + $include = if ($reference.ContainsKey('Include')) { $reference.Include } else { "..\$($reference.Name)\$($reference.Name).csproj" } + " " + } + " `n$($items -join "`n")`n " +} + +function Format-Packages($sdk, [bool]$include) { + if (-not $include) { return '' } + @" + + + + +"@ +} + +function Get-FixtureIdentityName([hashtable]$Fixture) { + "projectarch.$($Fixture.Id)".ToLowerInvariant() -replace '[^a-z0-9.\-]', '-' +} + +<# +.SYNOPSIS + Writes a fixture to $Root: App\App.csproj plus any referenced libraries. +#> +function New-ProjectArchFixture([hashtable]$Fixture, [string]$Root) { + if (Test-Path $Root) { Remove-Item $Root -Recurse -Force } + New-Item -ItemType Directory -Path $Root -Force | Out-Null + $sdk = $script:SdkMatrix[$Fixture.Sdk] + $app = $Fixture.App + + # Isolate the fixture from any Directory.Build.* above the work root. + $props = 'disable$(NoWarn);NU1603;NU1701' + if ($Fixture.ContainsKey('PlatformsInProps') -and $Fixture.PlatformsInProps) { $props += "$script:AllPlatforms" } + if ($Fixture.ContainsKey('UserCustomAfter') -and $Fixture.UserCustomAfter) { + # Set through an imported file, so only MSBuild evaluation (not the project text) shows it. + $props = $props.Replace('', '') + Write-FixtureFile "$Root\eng\Build.props" '$(MSBuildThisFileDirectory)..\custom.after.targets' + Write-FixtureFile "$Root\custom.after.targets" @' + + + + + +'@ + } + $props += '' + Write-FixtureFile "$Root\Directory.Build.props" $props + $targets = if ($Fixture.ContainsKey('TargetsXml')) { $Fixture.TargetsXml } else { '' } + Write-FixtureFile "$Root\Directory.Build.targets" "$targets" + + foreach ($library in @($Fixture['Libs'])) { + if (-not $library) { continue } + $name = $library.Name + $properties = [ordered]@{ RootNamespace = $name; Platforms = $library['Platforms']; RuntimeIdentifiers = $library['Rids'] } + $winui = $library.Kind -eq 'winui' + switch ($library.Kind) { + 'plain' { $properties.TargetFramework = 'netstandard2.0' } + 'exe' { + $properties.TargetFramework = $sdk.Tfm + $properties.OutputType = 'Exe' + } + 'generator' { + $properties.TargetFramework = 'netstandard2.0' + $properties.IsRoslynComponent = 'true' + $properties.EnforceExtendedAnalyzerRules = 'true' + $properties.LangVersion = 'latest' + } + 'multi' { $properties.TargetFrameworks = "netstandard2.0;$($sdk.Tfm)" } + 'winui' { + $properties.TargetFramework = $sdk.Tfm + $properties.TargetPlatformMinVersion = '10.0.17763.0' + $properties.UseWinUI = 'true' + $properties.WinUISDKReferences = 'false' + # WinAppSDK 1.6 WinUI libraries only build under the dotnet CLI with MSIX tooling enabled. + if ($Fixture.Sdk -eq '1.6') { $properties.EnableMsixTooling = 'true' } + } + } + if ($library.ContainsKey('Extra')) { foreach ($key in $library.Extra.Keys) { $properties[$key] = $library.Extra[$key] } } + $generatorPackages = if ($library.Kind -eq 'generator') { + " `n `n " + } + else { '' } + Write-FixtureFile "$Root\$name\$name.csproj" @" + + +$(Format-Properties $properties) + +$(Format-Packages $sdk $winui) +$generatorPackages +$(Format-References $library['Refs']) +$($library['Xml']) + +"@ + if ($library.Kind -eq 'generator') { + Write-FixtureFile "$Root\$name\Generator.cs" @" +using Microsoft.CodeAnalysis; + +namespace $name +{ + [Generator] + public sealed class Generator : IIncrementalGenerator + { + public void Initialize(IncrementalGeneratorInitializationContext context) => + context.RegisterPostInitializationOutput(output => output.AddSource( + "Generated.g.cs", + "namespace FixtureApp { internal static class Generated { public const string Value = \"ProjectArch\"; } }")); + } +} +"@ + continue + } + Write-FixtureFile "$Root\$name\Class1.cs" "namespace $name { public static class Class1 { public static string Hello() { return `"$name`"; } } }" + if ($library.Kind -eq 'exe') { + Write-FixtureFile "$Root\$name\Program.cs" "namespace $name { public static class Program { public static void Main() { System.Console.WriteLine(Class1.Hello()); } } }" + } + if ($winui) { + Write-FixtureFile "$Root\$name\Control1.xaml" @" + + + +"@ + Write-FixtureFile "$Root\$name\Control1.xaml.cs" "namespace $name { public sealed partial class Control1 : Microsoft.UI.Xaml.Controls.UserControl { public Control1() { InitializeComponent(); } } }" + } + } + + $winAppSdk = $app.Kind -eq 'winui' -or ($app.ContainsKey('WinAppSdk') -and $app.WinAppSdk) + $properties = [ordered]@{ + OutputType = if ($app.Kind -eq 'console') { 'Exe' } else { 'WinExe' } + RootNamespace = 'FixtureApp' + AssemblyName = 'FixtureApp' + Platforms = $app['Platforms'] + RuntimeIdentifiers = $app['Rids'] + TargetFramework = $sdk.Tfm + } + switch ($app.Kind) { + 'winui' { + $properties.TargetPlatformMinVersion = '10.0.17763.0' + $properties.UseWinUI = 'true' + $properties.WinUISDKReferences = 'false' + $properties.ApplicationManifest = 'app.manifest' + } + 'wpf' { $properties.UseWPF = 'true' } + } + if ($winAppSdk -and ($app.Packaged -or ($Fixture.Sdk -eq '1.6' -and $app.Kind -eq 'winui'))) { $properties.EnableMsixTooling = 'true' } + if ($winAppSdk -and -not $app.Packaged) { $properties.WindowsPackageType = 'None' } + if ($app.ContainsKey('SelfContained') -and $app.SelfContained) { $properties.WindowsAppSDKSelfContained = 'true' } + if ($app.ContainsKey('Extra')) { foreach ($key in $app.Extra.Keys) { $properties[$key] = $app.Extra[$key] } } + + $packaging = '' + if ($app.Packaged) { + Copy-Item $script:AssetsSource "$Root\App\Assets" -Recurse + $packaging = " `n `n " + if ($winAppSdk) { $packaging += "`n `n `n " } + Write-FixtureFile "$Root\App\Package.appxmanifest" @" + + + + ProjectArchProjectArchAssets\StoreLogo.png + + + + + + + + + +"@ + } + + Write-FixtureFile "$Root\App\App.csproj" @" + + +$(Format-Properties $properties) + +$packaging +$(Format-Packages $sdk $winAppSdk) +$(Format-References $app['Refs']) + +"@ + + switch ($app.Kind) { + 'winui' { + Write-FixtureFile "$Root\App\app.manifest" @" + + +"@ + Write-FixtureFile "$Root\App\App.xaml" @" + + + + + + + + + +"@ + Write-FixtureFile "$Root\App\App.xaml.cs" @" +using Microsoft.UI.Xaml; +namespace FixtureApp +{ + public partial class App : Application + { + private Window _window; + public App() { InitializeComponent(); } + protected override void OnLaunched(LaunchActivatedEventArgs args) { _window = new MainWindow(); _window.Activate(); } + } +} +"@ + Write-FixtureFile "$Root\App\MainWindow.xaml" @" + + + +"@ + Write-FixtureFile "$Root\App\MainWindow.xaml.cs" "namespace FixtureApp { public sealed partial class MainWindow : Microsoft.UI.Xaml.Window { public MainWindow() { InitializeComponent(); } } }" + } + 'wpf' { + Write-FixtureFile "$Root\App\Program.cs" "namespace FixtureApp { public static class Program { [System.STAThread] public static void Main() { new System.Windows.Application().Run(new System.Windows.Window { Title = `"ProjectArch`" }); } } }" + } + 'console' { + # A console app that references a source generator prints the generated value, so the build fails + # (CS0103) when the compiler can't load the generator. + $value = if (@($Fixture['Libs'] | Where-Object { $_ -and $_.Kind -eq 'generator' }).Count -gt 0) { 'Generated.Value' } else { '"ProjectArch"' } + Write-FixtureFile "$Root\App\Program.cs" "namespace FixtureApp { public static class Program { public static void Main() { System.Console.WriteLine($value); } } }" + } + } + + if ($Fixture.ContainsKey('PubXml')) { + foreach ($profileArch in 'x86', 'x64', 'arm64') { + $publishSettings = if ($Fixture.PubXml.Template) { + @" + False + False + True + False + True + +"@ + } + else { '' } + Write-FixtureFile "$Root\App\Properties\PublishProfiles\$($Fixture.PubXml.Name -f $profileArch)" @" + + + + FileSystem + $profileArch + win-$profileArch + bin\`$(Configuration)\`$(TargetFramework)\`$(RuntimeIdentifier)\publish\ + true +$publishSettings + +"@ + } + } + + if ($Fixture.ContainsKey('Sln') -and $Fixture.Sln) { + $projects = @('App\App.csproj') + @($Fixture['Libs'] | Where-Object { $_ } | ForEach-Object { "$($_.Name)\$($_.Name).csproj" }) + & dotnet new sln -n Fixture --format sln -o $Root 2>&1 | Out-Null + & dotnet sln "$Root\Fixture.sln" add @($projects | ForEach-Object { Join-Path $Root $_ }) 2>&1 | Out-Null + if ($LASTEXITCODE -ne 0) { throw "Could not create the solution for fixture $($Fixture.Id)." } + } +} + +<# +.SYNOPSIS + Returns the PE machine of an executable: arm64, x64, x86, other, or $null when missing. +#> +function Get-PeArchitecture([string]$Path) { + if (-not $Path -or -not (Test-Path $Path)) { return $null } + $stream = [IO.File]::OpenRead($Path) + try { + $reader = [IO.BinaryReader]::new($stream) + $stream.Position = 0x3C + $stream.Position = $reader.ReadInt32() + 4 + switch ($reader.ReadUInt16()) { 0xAA64 { 'arm64' } 0x8664 { 'x64' } 0x14c { 'x86' } default { 'other' } } + } + finally { $stream.Dispose() } +} + +<# +.SYNOPSIS + True when this machine can run an app built for $Architecture. +#> +function Test-CanLaunchArchitecture([string]$Architecture) { + $hostArch = [Runtime.InteropServices.RuntimeInformation]::OSArchitecture.ToString().ToLowerInvariant() + switch ($hostArch) { + 'arm64' { $true } + 'x64' { $Architecture -in 'x64', 'x86' } + default { $Architecture -eq $hostArch } + } +} + +<# +.SYNOPSIS + Returns the Win32 class name of a window (for example '#32770' for a dialog box). +#> +function Get-WindowClassName([IntPtr]$Handle) { + if (-not ('ProjectArch.NativeWindow' -as [type])) { + Add-Type -Namespace ProjectArch -Name NativeWindow -MemberDefinition @' +[System.Runtime.InteropServices.DllImport("user32.dll", CharSet = System.Runtime.InteropServices.CharSet.Unicode)] +public static extern int GetClassName(System.IntPtr hWnd, System.Text.StringBuilder className, int maxCount); +'@ + } + $builder = [System.Text.StringBuilder]::new(256) + $null = [ProjectArch.NativeWindow]::GetClassName($Handle, $builder, $builder.Capacity) + $builder.ToString() +} + +<# +.SYNOPSIS + Describes the processes started under $ParentId (recursively), for diagnosing a run that never exits. +#> +function Get-ProcessTreeDescription([int]$ParentId) { + $all = @(Get-CimInstance Win32_Process -ErrorAction SilentlyContinue) + $lines = [System.Collections.Generic.List[string]]::new() + $queue = [System.Collections.Generic.Queue[int]]::new() + $queue.Enqueue($ParentId) + while ($queue.Count -gt 0) { + $id = $queue.Dequeue() + foreach ($child in @($all | Where-Object { $_.ParentProcessId -eq $id })) { + $lines.Add(" child of ${id}: $($child.ProcessId) $($child.Name) $($child.CommandLine)") + $queue.Enqueue([int]$child.ProcessId) + } + } + if ($lines.Count -eq 0) { ' (no child processes)' } else { $lines -join "`n" } +} + +<# +.SYNOPSIS + Writes a full-memory dump of a process (for a stack of a run that never exits) and returns a description. +#> +function Save-ProcessDump([System.Diagnostics.Process]$Process, [string]$Path) { + if (-not ('ProjectArch.NativeDump' -as [type])) { + Add-Type -Namespace ProjectArch -Name NativeDump -MemberDefinition @' +[System.Runtime.InteropServices.DllImport("dbghelp.dll", SetLastError = true)] +public static extern bool MiniDumpWriteDump(System.IntPtr hProcess, int processId, Microsoft.Win32.SafeHandles.SafeFileHandle hFile, + int dumpType, System.IntPtr exceptionParam, System.IntPtr userStreamParam, System.IntPtr callbackParam); +'@ + } + try { + New-Item -ItemType Directory -Path (Split-Path $Path) -Force | Out-Null + $file = [IO.File]::Create($Path) + try { + # MiniDumpWithFullMemory | WithHandleData | WithUnloadedModules | WithThreadInfo + $ok = [ProjectArch.NativeDump]::MiniDumpWriteDump($Process.Handle, $Process.Id, $file.SafeFileHandle, 0x1026, + [IntPtr]::Zero, [IntPtr]::Zero, [IntPtr]::Zero) + $err = [Runtime.InteropServices.Marshal]::GetLastWin32Error() + } + finally { $file.Dispose() } + if ($ok) { "Dump: $Path" } else { "Dump failed (Win32 error $err)" } + } + catch { "Dump failed: $($_.Exception.Message)" } +} + +<# +.SYNOPSIS + Runs `winapp run` for a generated fixture with a hard timeout, so a hang fails one fixture instead of + the whole job. Launches the app (--detach) when this machine can run the architecture. +#> +function Invoke-ProjectArchRun { + param( + [Parameter(Mandatory)][hashtable]$Fixture, + [Parameter(Mandatory)][string]$Root, + [Parameter(Mandatory)][string]$Winapp, + [Parameter(Mandatory)][string]$Architecture, + [int]$TimeoutMinutes = 15, + [string]$DumpDirectory, + [switch]$NoLaunch + ) + + $launch = (Test-CanLaunchArchitecture $Architecture) -and -not $NoLaunch + $project = Join-Path $Root 'App\App.csproj' + $mode = if ($launch) { '--detach' } else { '--no-launch' } + $logPath = Join-Path $Root 'winapp-run.log' + $errorPath = Join-Path $Root 'winapp-run.err.log' + + $process = Start-Process -FilePath $Winapp -ArgumentList @('run', "`"$project`"", $mode, '--arch', $Architecture, '--json') ` + -WorkingDirectory $Root -NoNewWindow -PassThru -RedirectStandardOutput $logPath -RedirectStandardError $errorPath + $null = $process.Handle + $timedOut = -not $process.WaitForExit($TimeoutMinutes * 60 * 1000) + $hangDiagnostics = '' + if ($timedOut) { + $hangDiagnostics = "winapp (PID $($process.Id)) did not exit. Process tree:`n$(Get-ProcessTreeDescription $process.Id)" + if ($DumpDirectory) { + $hangDiagnostics += "`n" + (Save-ProcessDump $process (Join-Path $DumpDirectory "$($Fixture.Id)-winapp.dmp")) + } + & taskkill.exe /PID $process.Id /T /F 2>&1 | Out-Null + } + else { + $process.WaitForExit() + } + + $stdout = [string](Get-Content $logPath -Raw -ErrorAction SilentlyContinue) + $stderr = [string](Get-Content $errorPath -Raw -ErrorAction SilentlyContinue) + $processId = $null + $json = [regex]::Match($stdout, '\{[^{}]*"ProcessId"\s*:\s*(\d+)[^{}]*\}') + if ($json.Success) { $processId = [int]$json.Groups[1].Value } + + [pscustomobject]@{ + ExitCode = if ($timedOut) { $null } else { $process.ExitCode } + TimedOut = $timedOut + Launched = $launch + ProcessId = $processId + Output = "$stdout`n$stderr`n$hangDiagnostics" + } +} + +<# +.SYNOPSIS + Stops the launched app and removes the fixture's development package registration. +#> +function Remove-ProjectArchRun([hashtable]$Fixture, $Run) { + if ($Run -and $Run.ProcessId) { Stop-Process -Id $Run.ProcessId -Force -ErrorAction SilentlyContinue } + Get-AppxPackage -Name (Get-FixtureIdentityName $Fixture) -ErrorAction SilentlyContinue | + Remove-AppxPackage -ErrorAction SilentlyContinue +} + +Export-ModuleMember -Function Get-ProjectArchFixtures, New-ProjectArchFixture, Get-PeArchitecture, Get-WindowClassName, Test-CanLaunchArchitecture, Invoke-ProjectArchRun, Remove-ProjectArchRun, Get-FixtureIdentityName diff --git a/tests/project-arch/README.md b/tests/project-arch/README.md new file mode 100644 index 000000000..04b17ba10 --- /dev/null +++ b/tests/project-arch/README.md @@ -0,0 +1,53 @@ +# `winapp run` architecture matrix + +This suite runs `winapp run` against generated .NET projects that cover how the target architecture and project references reach MSBuild, then checks that each app: + +1. builds and registers (or launches unpackaged) without errors, +2. imports winapp's packaging-reference targets (`-p:CustomAfterMicrosoftCommonTargets=…`), unless the project sets that property itself, in which case winapp must keep the project's own value, +3. starts and shows its own window, not an error dialog (windowed apps, when this machine can run the architecture), and +4. is a PE image for the requested `--arch`. + +It guards the failure class behind [FluentStore-style](https://github.com/yoshiask/FluentStore) build errors such as `APPX1101: Payload contains two or more files with the same destination path`, `PRI175`/`PRI252` and `MSB3030`. Under the global `-r win-`, Windows App SDK packaging used to build a RID-agnostic referenced library a second time with the RID. A separate test (first shard only) builds such a graph without winapp's targets first, then checks that `winapp run` recovers without a clean. + +## Run it + +Build the CLI first (`.\scripts\build-cli.ps1`, or `dotnet build src\winapp-CLI\WinApp.Cli -c Debug -o `), then: + +```powershell +# Every fixture for the host architecture, using artifacts\cli\win-\winapp.exe +Invoke-Pester -Path .\tests\project-arch\ProjectArch.Tests.ps1 -Output Detailed + +# A specific CLI build, some fixtures, another architecture +$container = New-PesterContainer -Path .\tests\project-arch\ProjectArch.Tests.ps1 -Data @{ + WinappPath = '.\artifacts\cli\win-x64' + Fixture = 'R*', 'P08*' + Architecture = 'x64' +} +Invoke-Pester -Container $container -Output Detailed +``` + +| Parameter | Default | Meaning | +|---|---|---| +| `WinappPath` | `artifacts\cli\win-\winapp.exe`, then `winapp` on `PATH` | `winapp.exe` or its folder | +| `Architecture` | host architecture | value passed to `--arch` | +| `Fixture` | `*` | wildcard patterns over fixture IDs | +| `Shard` | all | `N/M`: every M-th fixture starting at N, used by CI | +| `WorkRoot` | `%TEMP%\winapp-project-arch` | where fixtures are generated | +| `TimeoutMinutes` | `15` | per-fixture limit for `winapp run` | +| `DumpDirectory` | none | write a full-memory dump of a `winapp run` that times out (CI uploads these on failure) | +| `SkipCleanup` | off | keep generated fixtures and `winapp-run.log` | + +Requirements: Windows with Developer Mode, the .NET 10 SDK plus the .NET 8 runtime (some fixtures target `net8.0`), the x86 .NET 8 and 10 runtimes for the `-x86` fixtures, Pester 5, and access to nuget.org. If `DOTNET_ROOT` points at your native .NET install, also set `DOTNET_ROOT_X86` to the x86 install (for example `C:\Program Files (x86)\dotnet`); otherwise unpackaged x86 apps load the wrong runtime and exit at startup. Fixtures run one at a time; each generated package is unregistered and its folder deleted afterwards. An architecture the machine can't run (for example `arm64` on an x64 host) is built and registered with `--no-launch`, and only the executable architecture is checked. + +## Fixtures + +`ProjectArch.psm1` defines the catalog in `Get-ProjectArchFixtures`. Each entry states what it guards. The axes are: + +- **App:** packaged WinUI, Windows App SDK self-contained, unpackaged, WPF, console; `` in the project, in `Directory.Build.props`, absent, or missing the target architecture. +- **References:** `netstandard2.0`, WinUI libraries with and without ``/``, libraries that enable MSIX tooling, multi-targeted (including a framework-conditional `RuntimeIdentifier`), diamond, `GlobalPropertiesToRemove="RuntimeIdentifier"`, `SetPlatform` metadata, `AnyCPU`-only, a referenced or build-order-only helper executable, and a solution. +- **Project settings:** .NET `SelfContained`, `PublishAot` built without `--aot`, a hard-coded or `win-$(Platform)` `RuntimeIdentifier`, Visual Studio publish profiles, a trimmed build whose `$(Platform)` profile makes it self-contained, `EnableDynamicPlatformResolution`, and a project-set `CustomAfterMicrosoftCommonTargets`. +- **Where a setting is declared:** on the app, on a referenced project, or through an import (`Directory.Build.props`/`.targets`, a `$(…)` path). A setting that matters on the app usually matters on a referenced project too, so cover both. +- **Architecture:** the host's, and `x86` (fixtures with an `-x86` suffix, plus `X01`–`X04` and `R25`), which is cross-architecture on both x64 and arm64 hosts. Project-referenced analyzers and source generators only break cross-architecture builds. +- **Windows App SDK:** 1.6, 1.8 and 2.x. + +To cover a new shape, add an entry with a short `Why`, run it against the current CLI, and confirm it fails without the fix it protects.