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)$($_.Name)>" }) -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.