diff --git a/.github/instructions/resizetizer.instructions.md b/.github/instructions/resizetizer.instructions.md new file mode 100644 index 000000000000..80a272e9e272 --- /dev/null +++ b/.github/instructions/resizetizer.instructions.md @@ -0,0 +1,330 @@ +--- +applyTo: + - "src/SingleProject/Resizetizer/**" + - ".buildtasks/Microsoft.Maui.Resizetizer.After.targets" +--- + +# Resizetizer MSBuild Targets Guidelines + +Guidance for working with .NET MAUI's Resizetizer build system, which processes images, fonts, splash screens, and assets at build time. + +> **See also:** .NET for Android's [MSBuild Best Practices](https://github.com/dotnet/android/blob/main/Documentation/guides/MSBuildBestPractices.md) is the canonical guide for MSBuild target authoring. Its *Incremental Builds*, *Stamp Files*, *FileWrites and IncrementalClean*, *When to not use Inputs and Outputs?*, and *Should I use BeforeTargets or AfterTargets?* sections directly inform the patterns documented below. + +## Architecture Overview + +The Resizetizer is an MSBuild-integrated pipeline that processes `MauiImage`, `MauiFont`, `MauiSplashScreen`, and `MauiAsset` items into platform-specific resources during the build. All core logic lives in a single file: + +**`src/SingleProject/Resizetizer/src/nuget/buildTransitive/Microsoft.Maui.Resizetizer.After.targets`** + +### File Loading Order + +These files are auto-imported by the NuGet package (`.props` / `.targets`), which then pulls in the named hook files: + +| File | Loaded | Purpose | +|------|--------|---------| +| `Microsoft.Maui.Resizetizer.props` | auto (package) | Early property defaults — currently empty (``) | +| `Microsoft.Maui.Resizetizer.targets` | auto (package) | **SDK registration**: sets `UsingMicrosoftMauiResizetizerSdk`, imports `Before.targets`, and appends `After.targets` to `AfterMicrosoftNETSdkTargets` | +| `Microsoft.Maui.Resizetizer.Before.targets` | imported by `.targets` | Pre-SDK target hooks — currently empty (``) | +| `Microsoft.Maui.Resizetizer.After.targets` | via `AfterMicrosoftNETSdkTargets` | **All logic** — targets, item registration, platform dispatch | + +`Microsoft.Maui.Resizetizer.targets` appends `After.targets` to `AfterMicrosoftNETSdkTargets`, ensuring the logic runs after the .NET SDK targets are loaded. + +### Local Testing with `.buildtasks/` + +The `.buildtasks/` directory at the repo root contains a local copy of the Resizetizer targets used by Sandbox and sample builds. It is **NOT git-tracked**. When testing MSBuild target changes locally: + +1. Edit the source file in `src/SingleProject/Resizetizer/src/nuget/buildTransitive/` +2. Copy it to `.buildtasks/Microsoft.Maui.Resizetizer.After.targets` +3. Build the Sandbox or sample project to test + +⚠️ Always remember to update both files. The `.buildtasks/` copy is what actually runs during local Sandbox builds. + +## Key Properties + +### Intermediate Output Paths + +```xml +<_ResizetizerIntermediateOutputPath>$(IntermediateOutputPath) +<_ResizetizerIntermediateOutputRoot>$(_ResizetizerIntermediateOutputPath)resizetizer\ +<_MauiIntermediateImages>...\resizetizer\r\ +<_MauiIntermediateFonts>...\resizetizer\f\ +<_MauiIntermediateSplashScreen>...\resizetizer\sp\ +``` + +**⚠️ CRITICAL**: `_ResizetizerIntermediateOutputPath` defaults to `$(IntermediateOutputPath)`, which **differs between outer and inner builds** in multi-targeting scenarios: +- Outer build: `obj/Release/net10.0-android/` +- Inner build (arm64): `obj/Release/net10.0-android/android-arm64/` + +This means stamp/manifest files, input tracking files, and intermediate outputs are at **different paths** in outer vs inner builds. + +### Incremental Build Tracking (Stamp & Output-Manifest Files) + +Two mechanisms drive the `Inputs`/`Outputs` up-to-date checks: + +| File | Tracks | Mechanism | +|------|--------|-----------| +| `mauifont.outputs` | Font processing (`ProcessMauiFonts`) | Output manifest | +| `mauisplash.outputs` | Splash screen processing (`ProcessMauiSplashScreens`) | Output manifest | +| `mauiimage.stamp` + `mauiimage.outputs` | Image resizing (`ResizetizeImages`) | Stamp + output manifest | +| `mauimanifest.stamp` | Platform manifest generation | Stamp | + +Each processing target (except `mauimanifest.stamp`) has a companion `.inputs` file containing serialized metadata for change detection. + +**⚠️ Prefer output manifests over bare stamp files.** A bare stamp (`` + `Outputs="$(stamp)"`) only records *when* a target last ran — its timestamp can stay newer than a generated output that was later deleted (e.g. a partial `obj` clean or concurrent build), so MSBuild wrongly treats the target as up-to-date and the package ships without the missing font/splash (regression #33092). Instead: + +1. Write the list of generated files to a `*.outputs` manifest with `WriteLinesToFile`. +2. Read it back **before** the up-to-date check via a `_Read*Outputs` target wired through `DependsOnTargets`. +3. Before the incremental check, detect missing files from the manifest and delete the + manifest when any are absent. +4. Use the manifest as the target's sole `Outputs`, e.g. `Outputs="$(_MauiFontOutputsFile)"`. + +Now if any generated file disappears, the read target invalidates the manifest, MSBuild sees +the missing output, and re-runs *only* that target. The target does not re-stamp unchanged +generated assets, so downstream consumers such as Android aapt2 avoid unnecessary work. +`ProcessMauiFonts` / `ProcessMauiSplashScreens` use this pattern; `ResizetizeImages` keeps +its legacy stamp alongside its own `mauiimage.outputs`. + +## Target Pipeline + +### Main Targets (Execution Order) + +``` +ResizetizeCollectItems ← Collects items from project + references + ├── ProcessMauiAssets ← Computes asset paths and registers platform items + ├── ProcessMauiSplashScreens ← Generates splash resources + ├── ProcessMauiFonts ← Copies font files (incremental) + │ └── _CollectMauiFontItems ← Registers platform items (ALWAYS runs) + └── ResizetizeImages ← Resizes images (incremental) +``` + +### Platform-Specific Scheduling + +| Platform | ResizetizeCollectItems | ProcessMauiFonts / _CollectMauiFontItems | ResizetizeImages | +|----------|----------------------|-----------------|-----------------| +| **iOS** | `CollectBundleResourcesDependsOn`, `CompileImageAssetsDependsOn` | `_CollectMauiFontItems` via `CollectAppManifestsDependsOn` | `AfterTargets=ResizetizeCollectItems` | +| **Android** | `BeforeTargets=_ComputeAndroidResourcePaths` | `AfterTargets=ResizetizeCollectItems` | `AfterTargets=ResizetizeCollectItems` | +| **Windows** | Via `DependsOnTargets` (from `ResizetizeImages`/`ProcessMauiFonts`) | `BeforeTargets=AssignTargetPaths` | `BeforeTargets=AssignTargetPaths` | +| **WPF** | Via `DependsOnTargets` (from `ResizetizeImages`/`ProcessMauiFonts`) | `BeforeTargets=FileClassification` | `BeforeTargets=FileClassification` | +| **Tizen** | Via `DependsOnTargets` (from `ResizetizeImages`/`ProcessMauiFonts`) | `AfterTargets=ResizetizeCollectItems` | `AfterTargets=ResizetizeCollectItems` | + +## ⚠️ Critical Pattern: Inputs/Outputs and Item Registration + +### The Problem + +MSBuild's `Inputs`/`Outputs` incremental check skips **targets** when outputs are up-to-date, but **still evaluates ItemGroups and PropertyGroups** via [output inference](https://learn.microsoft.com/en-us/visualstudio/msbuild/incremental-builds#output-inference). However, this is dangerous when ItemGroups depend on side-effects of skipped tasks: + +- Wildcard globs (`$(_MauiIntermediateFonts)*`) depend on files created by `Copy` tasks — if the intermediate directory is missing (partial clean, concurrent builds), the glob evaluates to nothing +- Tasks like `CreatePartialInfoPlistTask` are genuinely skipped — their output files won't exist if they haven't run + +### The Solution: Split Target Pattern + +**ALWAYS separate file-processing work from item registration into two targets:** + +1. **Processing target** (with `Inputs`/`Outputs`): Does the actual work (copy, resize, generate) +2. **Collection target** (NO `Inputs`/`Outputs`): Registers platform-specific items — always runs + +```xml + + + + + <_MauiFontOutput Include="@(MauiFont->'$(_MauiIntermediateFonts)%(Filename)%(Extension)')" /> + + + + + + + + + + + + <_MauiMissingFontOutput Include="@(_MauiFontOutputs)" + Condition="!Exists('%(_MauiFontOutputs.Identity)')" /> + + + + + + + + + + +``` + +### Item Collection Best Practices + +**✅ DO**: Use predictive path mapping from source items: +```xml +<_MauiFontCopied Include="@(MauiFont->'$(_MauiIntermediateFonts)%(Filename)%(Extension)')" /> +``` + +**❌ DON'T**: Use wildcard globs on intermediate directories — they can pick up stale files from deleted sources: +```xml + +<_MauiFontCopied Include="$(_MauiIntermediateFonts)*" /> +``` + +**Exception**: `ResizetizeImages` uses wildcard globs (`$(_MauiIntermediateImages)**\*`) because image resizing produces multiple output files per input (different sizes/densities). It compensates by explicitly deleting orphaned files. + +## Platform Item Registration + +### How Each Platform Receives Assets + +**Font Items** (from `_CollectMauiFontItems`): + +| Platform | Item Type | Metadata | +|----------|-----------|----------| +| **iOS** | `BundleResource` | `LogicalName`, `TargetPath` | +| **Android** | `AndroidAsset` | `Link` | +| **Windows** | `ContentWithTargetPath` | `TargetPath`, `CopyToPublishDirectory` | +| **WPF** | `Resource` | `LogicalName`, `Link` | +| **Tizen** | `TizenTpkUserIncludeFiles` | `TizenTpkSubDir` | + +**Image Items** (from `ResizetizeImages`): + +| Platform | Item Type | Metadata | +|----------|-----------|----------| +| **iOS** | `BundleResource` or `ImageAsset` | `LogicalName`, `TargetPath` (+ `Link` for ImageAsset) | +| **Android** | `LibraryResourceDirectories` | `StampFile` | +| **Windows** | `ContentWithTargetPath` | `TargetPath`, `CopyToPublishDirectory` | +| **WPF** | `Resource` | `LogicalName`, `Link` | +| **Tizen** | `TizenTpkUserIncludeFiles` | `TizenTpkSubDir` | + +### iOS-Specific: Info.plist Font Registration + +iOS requires fonts to be declared in Info.plist via `UIAppFonts`. The `CreatePartialInfoPlistTask` generates a `MauiInfo.plist` fragment, which is then added to `PartialAppManifest` for merging. + +**Important**: The plist generation (`CreatePartialInfoPlistTask`) is inside `ProcessMauiFonts` (the incremental target), while the `PartialAppManifest` registration is in `_CollectMauiFontItems` (always runs). This is correct because: +- The plist only needs regeneration when fonts change (handled by Inputs/Outputs) +- The plist FILE registration must happen every build (handled by always-run target using `Exists()` check) +- The generated `MauiInfo.plist` is added to `mauifont.outputs`, so deleting it also re-triggers `ProcessMauiFonts` +- Fonts are de-duplicated by intermediate filename before `CreatePartialInfoPlistTask` so colliding names (e.g. a project and a `ProjectReference` both shipping `OpenSans.ttf`) don't emit duplicate `UIAppFonts` entries + +## ResizetizeCollectItems + +This target is the starting point for the pipeline. It: + +1. Calls `GetMauiItems` on the project itself (if `ResizetizerIncludeSelfProject='True'`) +2. Calls `GetMauiItems` on all `@(ProjectReference)` projects (parallel MSBuild calls) +3. Aggregates `MauiImage`, `MauiIcon`, `MauiFont`, `MauiAsset`, `MauiSplashScreen` from all sources +4. Serializes item metadata to `.inputs` files for incremental change detection +5. Computes hashes for splash screen filename stability + +## MSBuild Scheduling Semantics + +### Understanding AfterTargets / BeforeTargets / DependsOnTargets + +**All three are hard requirements.** None of them are "hints" or "suggestions." + +| Mechanism | Meaning | When to Use | +|-----------|---------|-------------| +| `DependsOnTargets="X"` | "When I run, run X first (if it hasn't run)" | Hard dependency chain | +| `AfterTargets="X"` | "After X runs, run me" | Scheduling — ensures ordering | +| `BeforeTargets="X"` | "Before X runs, run me" | Scheduling — ensures ordering | + +**Key difference**: `DependsOnTargets` is **pull-based** (only runs if the depending target runs). `AfterTargets`/`BeforeTargets` are **push-based** (registers the target to run whenever the referenced target runs). + +**⚠️ CRITICAL**: `DependsOnTargets` alone does NOT trigger a target. Something must invoke the target first (via `AfterTargets`, `BeforeTargets`, or another target's `DependsOnTargets`). + +**✅ Prefer `DependsOnTargets` via an overridable property where possible.** Express ordering as `DependsOnTargets="$(_MyTargetDependsOn)"` and define the list as a property so consumers can extend the chain without editing the target. This is how the Resizetizer wires its read-manifest targets, e.g.: + +```xml + + + $(ProcessMauiFontsDependsOnTargets); + _ReadMauiFontOutputs; + + + +``` + +Reserve `AfterTargets`/`BeforeTargets` for one-off push-based hooks into SDK targets you don't own (e.g. `AssignTargetPaths`, `_ComputeAndroidResourcePaths`). See [Should I use BeforeTargets or AfterTargets?](https://github.com/dotnet/android/blob/main/Documentation/guides/MSBuildBestPractices.md#should-i-use-beforetargets-or-aftertargets). + +### Common Pitfall: Items Dependent on Task Side-Effects + +A wildcard glob over an intermediate directory populated by a task in the **same** target is usually fine on a normal incremental build: when the target is skipped as up-to-date, the files from the previous run are still on disk, so the glob still finds them. It becomes a problem in two specific cases: + +1. **The generated files are deleted out-of-band while the target still skips** — e.g. a partial `obj` clean, a concurrent/parallel build racing on the same intermediate folder, or a tool deleting intermediate files. If the up-to-date check is driven by a bare stamp whose timestamp stays newer than the (now missing) outputs, MSBuild skips the target, the glob finds nothing, and the item is silently dropped. **Fix:** track the real generated files in an output manifest (see [Incremental Build Tracking](#incremental-build-tracking-stamp--output-manifest-files)) so a missing output re-runs the target (issue #33092). +2. **A consumer needs the registered items even when the processing target is skipped or runs later in the schedule** — item registration that lives inside the incremental processing target is not guaranteed to be visible to the target that packages it. **Fix:** register platform items in a companion always-run collection target (issue #23268). + +Predictive mapping from the source items is also preferred over a filesystem glob because it never picks up stale files left over from deleted sources: + +```xml + + + + <_MauiFontCopied Include="$(_MauiIntermediateFonts)*" /> + + + + + <_MauiFontCopied Include="@(MauiFont->'$(_MauiIntermediateFonts)%(Filename)%(Extension)')" /> + +``` + +## Platform Detection Properties + +| Property | Detects | +|----------|---------| +| `_ResizetizerIsAndroidApp` | Android application (`AndroidApplication='True'`) | +| `_ResizetizerIsiOSApp` | iOS/MacCatalyst application (includes both) | +| `_ResizetizerIsWindowsAppSdk` | Windows App SDK (WinUI) | +| `_ResizetizerIsWPFApp` | WPF application | +| `_ResizetizerIsTizenApp` | Tizen application | +| `_ResizetizerIsCompatibleApp` | Any of the above | + +## Testing MSBuild Target Changes + +### Build Verification + +```bash +# 1. Copy updated targets to .buildtasks/ +cp src/SingleProject/Resizetizer/src/nuget/buildTransitive/Microsoft.Maui.Resizetizer.After.targets \ + .buildtasks/Microsoft.Maui.Resizetizer.After.targets + +# 2. Clean build test +rm -rf artifacts/obj/Maui.Controls.Sample.Sandbox/Release/net10.0-android/ +dotnet build src/Controls/samples/Controls.Sample.Sandbox/Maui.Controls.Sample.Sandbox.csproj \ + -f:net10.0-android -c:Release --no-restore + +# 3. Verify fonts exist +find artifacts/obj/Maui.Controls.Sample.Sandbox/Release/net10.0-android/ -path "*/assets/*.ttf" | wc -l + +# 4. Incremental build test (no changes) +dotnet build ... (same command) +# Verify fonts still present + +# 5. Diagnostic build to verify target execution +dotnet build ... -v:diag 2>&1 | grep -E "ProcessMauiFonts|_CollectMauiFontItems|Skipping" +``` + +### Key Things to Verify + +- **Clean build**: All assets appear in output +- **Incremental build**: Processing targets SKIP, collection targets RUN, assets still present +- **No unnecessary downstream work**: Platform asset targets (e.g., `_GenerateAndroidAssetsDir`) should skip when fonts haven't changed +- **Modified input**: Touching a font source file should cause `ProcessMauiFonts` to re-run + +## Common Mistakes + +| Mistake | Impact | Correct Approach | +|---------|--------|-----------------| +| Use wildcard glob dependent on task output | Glob finds nothing if task was skipped (output inference) | Use predictive path mapping from source items | +| Put task-dependent logic in same target as work | During output inference, tasks are skipped but ItemGroups evaluate | Use split target pattern | +| Forget to copy changes to `.buildtasks/` | Local testing uses old code | Always copy after editing source | +| Assume `DependsOnTargets` triggers execution | Target never runs | Add `AfterTargets` or `BeforeTargets` trigger | +| Mix up `AfterTargets` vs `DependsOnTargets` | Both are hard requirements, but serve different purposes | `DependsOnTargets` = pull, `AfterTargets` = push | +| Assume target body is fully skipped by Inputs/Outputs | ItemGroups ARE evaluated via output inference; only tasks are skipped | Be aware of output inference; don't rely on it for task-dependent items | diff --git a/src/SingleProject/Resizetizer/src/nuget/buildTransitive/Microsoft.Maui.Resizetizer.After.targets b/src/SingleProject/Resizetizer/src/nuget/buildTransitive/Microsoft.Maui.Resizetizer.After.targets index 1939d0e790e2..74cd94653edd 100644 --- a/src/SingleProject/Resizetizer/src/nuget/buildTransitive/Microsoft.Maui.Resizetizer.After.targets +++ b/src/SingleProject/Resizetizer/src/nuget/buildTransitive/Microsoft.Maui.Resizetizer.After.targets @@ -75,9 +75,9 @@ <_ResizetizerOutputsFile>$(_ResizetizerIntermediateOutputPath)mauiimage.outputs <_ResizetizerStampFile>$(_ResizetizerIntermediateOutputPath)mauiimage.stamp <_MauiFontInputsFile>$(_ResizetizerIntermediateOutputPath)mauifont.inputs - <_MauiFontStampFile>$(_ResizetizerIntermediateOutputPath)mauifont.stamp + <_MauiFontOutputsFile>$(_ResizetizerIntermediateOutputPath)mauifont.outputs <_MauiSplashInputsFile>$(_ResizetizerIntermediateOutputPath)mauisplash.inputs - <_MauiSplashStampFile>$(_ResizetizerIntermediateOutputPath)mauisplash.stamp + <_MauiSplashOutputsFile>$(_ResizetizerIntermediateOutputPath)mauisplash.outputs <_MauiManifestStampFile>$(_ResizetizerIntermediateOutputPath)mauimanifest.stamp <_ResizetizerIntermediateOutputRoot>$(_ResizetizerIntermediateOutputPath)resizetizer\ @@ -120,11 +120,16 @@ ProcessMauiSplashScreens; _ReadResizetizeImagesOutputs; + + $(ProcessMauiSplashScreensDependsOnTargets); + _ReadMauiSplashOutputs; + $(ProcessMauiFontsDependsOnTargets); ResizetizeCollectItems; ProcessMauiAssets; ProcessMauiSplashScreens; + _ReadMauiFontOutputs; @@ -151,7 +156,7 @@ - ProcessMauiFonts; + _CollectMauiFontItems; ProcessMauiSplashScreens; $(CollectAppManifestsDependsOn) @@ -399,7 +404,8 @@ + Outputs="$(_MauiSplashOutputsFile)" + DependsOnTargets="$(ProcessMauiSplashScreensDependsOnTargets)"> <_MauiHasSplashScreens>false @@ -505,21 +511,40 @@ - + - + + - + + + + + + + <_MauiMissingSplashOutput Include="@(_MauiSplashOutputs)" Condition="!Exists('%(_MauiSplashOutputs.Identity)')" /> + + + + @@ -530,8 +555,91 @@ DestinationFolder="$(_MauiIntermediateFonts)" SkipUnchangedFiles="true" /> + + + <_MauiFontOutput Include="@(MauiFont->'$(_MauiIntermediateFonts)%(Filename)%(Extension)')" /> + + + + + + + + + + + + - <_MauiFontCopied Include="$(_MauiIntermediateFonts)*" /> + <_MauiFontOutputUnique Include="$(_MauiIntermediateFonts)MauiInfo.plist" + Condition="'$(_ResizetizerIsiOSApp)' == 'True' And Exists('$(_MauiIntermediateFonts)MauiInfo.plist')" /> + + + + + + + + + + + + + + + + + + + <_MauiFontCopied Include="@(MauiFont->'$(_MauiIntermediateFonts)%(Filename)%(Extension)')" /> + + + + + + + + <_MauiFontCopied Remove="@(_MauiFontCopied)" /> + <_MauiFontCopied Include="@(_MauiFontCopiedUnique)" /> @@ -547,13 +655,6 @@ - - - <_MauiFontPListFiles Include="$(_MauiIntermediateFonts)MauiInfo.plist" Condition="Exists('$(_MauiIntermediateFonts)MauiInfo.plist')" /> @@ -595,16 +696,22 @@ - - - - + - + + + + + + <_MauiMissingFontOutput Include="@(_MauiFontOutputs)" Condition="!Exists('%(_MauiFontOutputs.Identity)')" /> + + + + diff --git a/src/TestUtils/src/Microsoft.Maui.IntegrationTests/ResizetizerTests.cs b/src/TestUtils/src/Microsoft.Maui.IntegrationTests/ResizetizerTests.cs index a8805a4454e0..38938afee33c 100644 --- a/src/TestUtils/src/Microsoft.Maui.IntegrationTests/ResizetizerTests.cs +++ b/src/TestUtils/src/Microsoft.Maui.IntegrationTests/ResizetizerTests.cs @@ -1,3 +1,6 @@ +using Microsoft.Build.Framework; +using Microsoft.Build.Logging.StructuredLogger; + namespace Microsoft.Maui.IntegrationTests; [Trait("Category", "Build")] @@ -100,6 +103,277 @@ public void CollectsAssets(string id, string libid, bool unpackaged) "Windows was missing the image file."); } + // Regression test for https://github.com/dotnet/maui/issues/23268: custom font assets were not + // copied into the Android assets folder on the *first* (clean) Release build — they only showed + // up after a second build. The fix makes _CollectMauiFontItems always run and map font paths + // predictively from @(MauiFont) instead of relying on a filesystem glob that was empty during + // first-build output inference. Release is required: the bug only reproduced in Release. + [Theory] + [InlineData("Release")] + public void FontsAreCopiedToAndroidAssetsOnFirstBuild(string config) + { + SetTestIdentifier(config); + + var projectDir = TestDirectory; + var projectFile = Path.Combine(projectDir, $"{Path.GetFileName(projectDir)}.csproj"); + + // The default maui template registers , which includes + // OpenSans-Regular.ttf, so building it exercises the font pipeline without extra assets. + Assert.True(DotnetInternal.New("maui", projectDir, DotNetCurrent, output: _output), + "Unable to create template maui. Check test output for errors."); + + var framework = $"{DotNetCurrent}-android"; + var androidObjDir = Path.Combine(projectDir, "obj", config, framework); + const string fontFileName = "OpenSans-Regular.ttf"; + + // First (clean) build for Android only. + var firstBinlog = Path.Combine(projectDir, "first.binlog"); + Assert.True(DotnetInternal.Build(projectFile, config, framework: framework, properties: BuildProps, binlogPath: firstBinlog, output: _output), + $"Project {Path.GetFileName(projectFile)} failed to build (first build). Check test output/attachments for errors."); + + Assert.True(FontExistsInAndroidAssets(androidObjDir, fontFileName), + $"Font '{fontFileName}' was not copied into the Android assets folder under '{androidObjDir}' on the first build (regression #23268)."); + + // Second (incremental) build. + var secondBinlog = Path.Combine(projectDir, "second.binlog"); + Assert.True(DotnetInternal.Build(projectFile, config, framework: framework, properties: BuildProps, binlogPath: secondBinlog, output: _output), + $"Project {Path.GetFileName(projectFile)} failed to build (incremental build). Check test output/attachments for errors."); + + // ProcessMauiFonts is incremental and should be skipped (up-to-date) on the second build, + // while the always-run _CollectMauiFontItems must still execute and re-register the items. + Assert.True(WasTargetSkipped(secondBinlog, "ProcessMauiFonts"), + "ProcessMauiFonts should have been skipped (up-to-date) on the incremental build."); + Assert.True(WasTargetExecuted(secondBinlog, "_CollectMauiFontItems"), + "_CollectMauiFontItems should run on every build, even when ProcessMauiFonts is skipped."); + Assert.True(FontExistsInAndroidAssets(androidObjDir, fontFileName), + $"Font '{fontFileName}' is missing from the Android assets folder after an incremental build."); + } + + // Regression test for https://github.com/dotnet/maui/issues/33092 (consolidated from #35962): + // after a successful build, deleting the generated font/splash intermediate outputs must + // re-trigger ProcessMauiFonts / ProcessMauiSplashScreens on the next build. This is guaranteed by + // tracking the generated files in mauifont.outputs / mauisplash.outputs manifests that feed the + // targets' Outputs (see _ReadMauiFontOutputs / _ReadMauiSplashOutputs), replacing the old stamp + // files whose timestamps could stay newer than the deleted outputs. It first deletes only the + // Apple MauiInfo.plist to verify that its separate manifest entry re-triggers font processing. + // A final no-op build asserts both targets are skipped when nothing changed. + [Fact] + public void BuildRegeneratesFontsAndSplashWhenIntermediateOutputsAreMissing() + { + SetTestIdentifier("MissingResizetizerOutputs"); + + // Builds every TFM of the default maui template (Android + iOS + MacCatalyst), which only + // fully builds on macOS, so this is gated accordingly. The Android first-build path is + // additionally covered on Windows by FontsAreCopiedToAndroidAssetsOnFirstBuild. + if (!TestEnvironment.IsMacOS) + return; // Skip: building the Apple TFMs (iOS/MacCatalyst) is only supported on macOS. + + var projectDir = TestDirectory; + var projectFile = Path.Combine(projectDir, $"{Path.GetFileName(projectDir)}.csproj"); + const string config = "Debug"; + + Assert.True(DotnetInternal.New("maui", projectDir, DotNetCurrent, output: _output), + $"Unable to create template maui. Check test output for errors."); + + Assert.True(DotnetInternal.Build(projectFile, config, properties: BuildProps, output: _output), + $"Project {Path.GetFileName(projectFile)} failed to build. Check test output/attachments for errors."); + + var intermediateOutputRoots = GetResizetizerOutputRoots(projectDir, config); + AssertBuiltTargetPlatforms(intermediateOutputRoots); + AssertIntermediateOutputsExist(intermediateOutputRoots); + + var appleOutputRoots = intermediateOutputRoots + .Where(IsAppleTargetFramework) + .ToArray(); + Assert.NotEmpty(appleOutputRoots); + + foreach (var appleOutputRoot in appleOutputRoots) + { + var plistPath = Path.Combine(appleOutputRoot, "resizetizer", "f", "MauiInfo.plist"); + Assert.True(File.Exists(plistPath), $"Missing generated font plist '{plistPath}'."); + File.Delete(plistPath); + } + + var plistRecoveryBinlogPath = Path.Combine(projectDir, "plist-recovery.binlog"); + Assert.True(DotnetInternal.Build(projectFile, config, properties: BuildProps, binlogPath: plistRecoveryBinlogPath, output: _output), + $"Project {Path.GetFileName(projectFile)} failed to rebuild missing font plists. Check test output/attachments for errors."); + + AssertTargetExecuted(plistRecoveryBinlogPath, "ProcessMauiFonts", appleOutputRoots.Length); + + foreach (var appleOutputRoot in appleOutputRoots) + { + var plistPath = Path.Combine(appleOutputRoot, "resizetizer", "f", "MauiInfo.plist"); + Assert.True(File.Exists(plistPath), $"Missing regenerated font plist '{plistPath}'."); + } + + foreach (var intermediateOutputRoot in intermediateOutputRoots) + { + DeleteDirectory(Path.Combine(intermediateOutputRoot, "resizetizer", "f")); + DeleteDirectory(Path.Combine(intermediateOutputRoot, "resizetizer", "sp")); + } + + Assert.True(DotnetInternal.Build(projectFile, config, properties: BuildProps, output: _output), + $"Project {Path.GetFileName(projectFile)} failed to rebuild. Check test output/attachments for errors."); + + AssertIntermediateOutputsExist(intermediateOutputRoots); + + var noOpBinlogPath = Path.Combine(projectDir, "no-op.binlog"); + Assert.True(DotnetInternal.Build(projectFile, config, properties: BuildProps, binlogPath: noOpBinlogPath, output: _output), + $"Project {Path.GetFileName(projectFile)} failed to no-op rebuild. Check test output/attachments for errors."); + + AssertTargetSkipped(noOpBinlogPath, "ProcessMauiFonts", intermediateOutputRoots.Count); + AssertTargetSkipped(noOpBinlogPath, "ProcessMauiSplashScreens", intermediateOutputRoots.Count); + AssertIntermediateOutputsExist(intermediateOutputRoots); + } + + static bool FontExistsInAndroidAssets(string androidObjDir, string fontFileName) + { + // A registered AndroidAsset is staged by .NET for Android into $(IntermediateOutputPath)assets/, + // i.e. obj/{config}/{tfm}/assets/ — a deterministic path (no RID segment for a default build). + // The intermediate resizetizer copy under resizetizer/f/ is NOT proof of registration; only the + // staged copy under assets/ is. Confirmed empirically with a Release net*-android build. + return File.Exists(Path.Combine(androidObjDir, "assets", fontFileName)); + } + + static bool WasTargetSkipped(string binlogPath, string targetName) + { + var (started, upToDateSkips) = GetTargetStatus(binlogPath, targetName); + // Each project instance that reaches the target emits exactly one TargetStarted, followed by + // either an OutputsUpToDate skip (up-to-date) or real task execution (no OutputsUpToDate skip). + // PreviouslyBuiltSuccessfully skips from extra request edges add no TargetStarted, so the + // target is "skipped" only when it ran at least once and *every* started instance was + // up-to-date. Requiring started == upToDateSkips (rather than upToDateSkips > 0) avoids a false + // positive if some instance actually executed while another was up-to-date. + return started > 0 && started == upToDateSkips; + } + + static bool WasTargetExecuted(string binlogPath, string targetName) + { + var (started, upToDateSkips) = GetTargetStatus(binlogPath, targetName); + // Executions = started instances that did NOT end in an OutputsUpToDate skip. An always-run + // target (no Inputs/Outputs) requested via several edges still only adds + // PreviouslyBuiltSuccessfully skips (no OutputsUpToDate), so it correctly counts as executed. + return started - upToDateSkips > 0; + } + + // Returns the number of TargetStarted events and the number of *up-to-date* skips + // (TargetSkipReason.OutputsUpToDate) for the target. Counting only OutputsUpToDate — rather than + // every TargetSkipped — is essential: when a target is requested through multiple edges (its own + // AfterTargets plus other targets' DependsOnTargets, e.g. ProcessMauiFonts pulled in by + // _CollectMauiFontItems and _ComputeAndroidResourcePaths), MSBuild emits a single OutputsUpToDate + // skip plus one PreviouslyBuiltSuccessfully skip per extra edge. A naive "any skip" or + // "started == skipped" check therefore misclassifies real multi-target builds. + static (int started, int upToDateSkips) GetTargetStatus(string binlogPath, string targetName) + { + int started = 0; + int upToDateSkips = 0; + if (File.Exists(binlogPath)) + { + foreach (var record in new BinLogReader().ReadRecords(binlogPath)) + { + switch (record.Args) + { + case TargetStartedEventArgs s when string.Equals(s.TargetName, targetName, StringComparison.Ordinal): + started++; + break; + case TargetSkippedEventArgs sk when string.Equals(sk.TargetName, targetName, StringComparison.Ordinal) + && sk.SkipReason == TargetSkipReason.OutputsUpToDate: + upToDateSkips++; + break; + } + } + } + return (started, upToDateSkips); + } + + static IReadOnlyList GetResizetizerOutputRoots(string projectDir, string config) + { + var intermediateOutputPath = Path.Combine(projectDir, "obj", config); + var outputRoots = Directory + .GetFiles(intermediateOutputPath, "mauifont.outputs", SearchOption.AllDirectories) + .Select(Path.GetDirectoryName) + .Where(root => root is not null && File.Exists(Path.Combine(root, "mauisplash.outputs"))) + .Cast() + .OrderBy(root => root, StringComparer.OrdinalIgnoreCase) + .ToArray(); + + Assert.NotEmpty(outputRoots); + return outputRoots; + } + + static void AssertBuiltTargetPlatforms(IReadOnlyList intermediateOutputRoots) + { + // This is only reached from BuildRegeneratesFontsAndSplashWhenIntermediateOutputsAreMissing, + // which is gated to macOS (it builds the Apple TFMs), so only the Apple + Android roots are + // asserted here. Windows is covered separately by FontsAreCopiedToAndroidAssetsOnFirstBuild's + // sibling Windows lanes and CollectsAssets, and is never built by this macOS-only test. + Assert.Contains(intermediateOutputRoots, root => ContainsTargetFramework(root, $"{DotNetCurrent}-android")); + Assert.Contains(intermediateOutputRoots, root => ContainsTargetFramework(root, $"{DotNetCurrent}-ios")); + Assert.Contains(intermediateOutputRoots, root => ContainsTargetFramework(root, $"{DotNetCurrent}-maccatalyst")); + } + + static bool ContainsTargetFramework(string path, string targetFramework) => + path.Contains(targetFramework, StringComparison.OrdinalIgnoreCase); + + static bool IsAppleTargetFramework(string path) => + ContainsTargetFramework(path, $"{DotNetCurrent}-ios") || + ContainsTargetFramework(path, $"{DotNetCurrent}-maccatalyst"); + + static void AssertIntermediateOutputsExist(IReadOnlyList intermediateOutputRoots) + { + foreach (var intermediateOutputRoot in intermediateOutputRoots) + { + var fontsDir = Path.Combine(intermediateOutputRoot, "resizetizer", "f"); + Assert.True(File.Exists(Path.Combine(fontsDir, "OpenSans-Regular.ttf")), + $"Missing OpenSans-Regular.ttf in {fontsDir}."); + Assert.True(File.Exists(Path.Combine(fontsDir, "OpenSans-Semibold.ttf")), + $"Missing OpenSans-Semibold.ttf in {fontsDir}."); + + if (!ContainsTargetFramework(intermediateOutputRoot, $"{DotNetCurrent}-maccatalyst")) + { + var splashDir = Path.Combine(intermediateOutputRoot, "resizetizer", "sp"); + // Assert a deterministic generated splash marker per platform instead of scanning the + // whole directory (confirmed with Release builds): + // - Android: resizetizer/sp/drawable/maui_splash_image.xml + // - iOS: resizetizer/sp/MauiSplash.storyboard + var splashMarker = ContainsTargetFramework(intermediateOutputRoot, $"{DotNetCurrent}-android") + ? Path.Combine(splashDir, "drawable", "maui_splash_image.xml") + : Path.Combine(splashDir, "MauiSplash.storyboard"); + Assert.True(File.Exists(splashMarker), + $"Missing generated splash marker '{splashMarker}'."); + } + } + } + + static void DeleteDirectory(string path) + { + if (Directory.Exists(path)) + Directory.Delete(path, recursive: true); + } + + static void AssertTargetSkipped(string binlogPath, string targetName, int minimumSkipCount) + { + Assert.True(File.Exists(binlogPath), $"Binlog not found: {binlogPath}"); + + // Count only up-to-date (OutputsUpToDate) skips — one per platform/project instance on a no-op + // build — via TargetSkippedEventArgs.SkipReason, instead of matching localized log text or + // counting the PreviouslyBuiltSuccessfully skips emitted for extra request edges. + var (_, upToDateSkips) = GetTargetStatus(binlogPath, targetName); + + Assert.True(upToDateSkips >= minimumSkipCount, + $"Expected target '{targetName}' to be skipped as up-to-date at least {minimumSkipCount} times, but found {upToDateSkips}. See binlog: {binlogPath}"); + } + + static void AssertTargetExecuted(string binlogPath, string targetName, int minimumExecutionCount) + { + Assert.True(File.Exists(binlogPath), $"Binlog not found: {binlogPath}"); + + var (started, upToDateSkips) = GetTargetStatus(binlogPath, targetName); + var executions = started - upToDateSkips; + + Assert.True(executions >= minimumExecutionCount, + $"Expected target '{targetName}' to execute at least {minimumExecutionCount} times, but found {executions}. See binlog: {binlogPath}"); + } [Theory] [InlineData("maui", "mauilib", true)] [InlineData("maui", "mauilib", false)]