# Phase 5: Distribution and Hardening - Research **Researched:** 2026-04-03 **Domain:** .NET 10 WPF single-file publishing, localization completeness, reliability verification **Confidence:** HIGH --- ## Phase Requirements | ID | Description | Research Support | |----|-------------|-----------------| | FOUND-11 | Self-contained single EXE distribution — no .NET runtime dependency for end users | dotnet publish -r win-x64 --self-contained true -p:PublishSingleFile=true -p:IncludeNativeLibrariesForSelfExtract=true produces single EXE | --- ## Summary Phase 5 delivers the final shipping artifact: a self-contained Windows EXE that runs without any pre-installed .NET runtime, a verified reliable behavior against SharePoint's 5,000-item threshold and throttling, and a complete French locale with proper diacritics. The core publish mechanism is already proven to work: `dotnet publish -r win-x64 --self-contained true -p:PublishSingleFile=true -p:IncludeNativeLibrariesForSelfExtract=true` was tested against the current codebase and produces exactly two output files — `SharepointToolbox.exe` (~201 MB, self-extracting) and `SharepointToolbox.pdb`. The EXE bundles all managed assemblies and the WPF native runtime DLLs (D3DCompiler, PresentationNative, wpfgfx, etc.) by extraction into `%TEMP%/.net` on first run. The `msalruntime.dll` WAM broker is also bundled. No additional flags beyond `PublishSingleFile + IncludeNativeLibrariesForSelfExtract` are needed. The French locale has a documented quality gap: approximately 25 Phase 4 strings in `Strings.fr.resx` are missing required French diacritics (e.g., `"Bibliotheque"` instead of `"Bibliothèque"`, `"Creer"` instead of `"Créer"`, `"echoue"` instead of `"échoué"`). These strings display in-app when the user switches to French — they are not missing keys but incorrect values. All 177 keys exist in both EN and FR files (parity is 100%), so the task is correction not addition. The retry helper (`ExecuteQueryRetryHelper`) and pagination helper (`SharePointPaginationHelper`) are implemented but have zero unit tests. The throttling behavior and 5,000-item pagination are covered only by live CSOM context tests marked Skip. Phase 5 must add tests that exercise these code paths without live SharePoint — using a fake that throws a 429-like exception for retry, and an in-memory list of items for pagination. **Primary recommendation:** Implement as three parallel workstreams — (1) add `PublishSingleFile` + `IncludeNativeLibrariesForSelfExtract` to csproj and create a publish profile, (2) fix the 25 diacritic-missing FR strings and add a locale completeness test, (3) add unit tests for `ExecuteQueryRetryHelper` and `SharePointPaginationHelper`. --- ## Standard Stack ### Core | Library | Version | Purpose | Why Standard | |---------|---------|---------|--------------| | .NET SDK publish CLI | 10.0.200 | Self-contained single-file packaging | Built-in, no extra tool | | xUnit | 2.9.3 | Unit tests for retry/pagination helpers | Already in test project | | Moq | 4.20.72 | Mock `ClientContext` for retry test isolation | Already in test project | ### No New Dependencies Phase 5 adds zero NuGet packages. All capability is already in the SDK and test infrastructure. **Publish command (verified working):** ```bash dotnet publish SharepointToolbox/SharepointToolbox.csproj \ -c Release \ -r win-x64 \ --self-contained true \ -p:PublishSingleFile=true \ -p:IncludeNativeLibrariesForSelfExtract=true \ -o ./publish ``` **Output (verified):** - `SharepointToolbox.exe` — ~201 MB self-contained EXE - `SharepointToolbox.pdb` — symbols (can be excluded in release by setting `none`) --- ## Architecture Patterns ### Recommended Project Structure Changes ``` SharepointToolbox/ ├── SharepointToolbox.csproj # Add publish properties └── Properties/ └── PublishProfiles/ └── win-x64.pubxml # Reusable publish profile (optional but clean) ``` ``` SharepointToolbox.Tests/ └── Services/ ├── ExecuteQueryRetryHelperTests.cs # NEW — retry/throttling tests └── SharePointPaginationHelperTests.cs # NEW — pagination tests ``` ### Pattern 1: Self-Contained Single-File Publish Configuration **What:** Set publish properties in .csproj so `dotnet publish` requires no extra flags **When to use:** Preferred over CLI-only flags — reproducible builds, CI compatibility ```xml true true win-x64 true ``` **Important:** `PublishSingleFile` must NOT be combined with `PublishTrimmed=true`. The project already has `false` — this is correct and must remain false (PnP.Framework and MSAL use reflection). ### Pattern 2: Testing `ExecuteQueryRetryHelper` Without Live CSOM **What:** Verify retry behavior by injecting a fake exception into `ClientContext.ExecuteQueryAsync` **When to use:** `ClientContext` cannot be instantiated without a live SharePoint URL The existing `ExecuteQueryRetryHelper.ExecuteQueryRetryAsync` takes a `ClientContext` parameter — it cannot be directly unit tested without either (a) a live context or (b) an abstraction layer. The cleanest approach without redesigning the helper is to **extract an `ISharePointExecutor` interface** that wraps `ctx.ExecuteQueryAsync()` and inject a fake that throws then succeeds. ```csharp // Proposed thin abstraction — no CSOM dependency in tests public interface ISharePointExecutor { Task ExecuteAsync(CancellationToken ct = default); } // Production adapter public class ClientContextExecutor : ISharePointExecutor { private readonly ClientContext _ctx; public ClientContextExecutor(ClientContext ctx) => _ctx = ctx; public Task ExecuteAsync(CancellationToken ct) => _ctx.ExecuteQueryAsync(); } ``` Then `ExecuteQueryRetryHelper.ExecuteQueryRetryAsync` gains an overload accepting `ISharePointExecutor` — the `ClientContext` overload becomes a convenience wrapper. Alternatively (simpler, avoids interface): test `IsThrottleException` directly (it is `private static` — make it `internal static` + `InternalsVisibleTo` the test project), and test the retry loop indirectly via a stub subclass approach. **Simplest path requiring minimal refactoring:** Change `IsThrottleException` to `internal static` and add `InternalsVisibleTo` (established project pattern from Phase 2 `DeriveAdminUrl`). Test the exception classification directly. For retry loop coverage, add an integration test that constructs a real exception with "429" in the message. ### Pattern 3: Testing `SharePointPaginationHelper` Without Live CSOM **What:** The `GetAllItemsAsync` method uses `ctx.ExecuteQueryAsync()` internally — cannot be unit tested without a live context **When to use:** Pagination logic must be verified without a live tenant The pagination helper's core correctness is in `BuildPagedViewXml` (private static). The approach: 1. Make `BuildPagedViewXml` internal static (parallel to `DeriveAdminUrl` precedent) 2. Unit test the XML injection logic directly 3. Mark the live pagination test as Skip with a clear comment explaining the 5,000-item guarantee comes from the `ListItemCollectionPosition` loop For the success criterion "scan against a library with more than 5,000 items returns complete, correct results", the verification is a **manual smoke test** against a real tenant — it cannot be automated without a live SharePoint environment. ### Pattern 4: Locale Completeness Automated Test **What:** Assert that every key in `Strings.resx` has a non-empty, non-bracketed value in `Strings.fr.resx` **When to use:** Prevents future regressions where new EN keys are added without FR equivalents ```csharp // Source: .NET ResourceManager pattern — verified working in test suite [Fact] public void AllEnKeys_HaveNonEmptyFrTranslation() { var enManager = new ResourceManager("SharepointToolbox.Localization.Strings", typeof(Strings).Assembly); var frCulture = new CultureInfo("fr-FR"); // Get all EN keys by switching to invariant and enumerating var resourceSet = enManager.GetResourceSet(CultureInfo.InvariantCulture, true, true); foreach (DictionaryEntry entry in resourceSet!) { var key = (string)entry.Key; var frValue = enManager.GetString(key, frCulture); Assert.False(string.IsNullOrWhiteSpace(frValue), $"Key '{key}' has no French translation."); Assert.DoesNotContain("[", frValue!, $"Key '{key}' returns bracketed fallback in French."); } } ``` **Note:** The existing `TranslationSourceTests.Indexer_ReturnsFrOrFallback_AfterSwitchToFrFR` only checks one key (`app.title`). This new test checks all 177 keys exhaustively. ### Anti-Patterns to Avoid - **Adding `PublishTrimmed=true`:** PnP.Framework and MSAL both use reflection; trimming will silently break authentication and CSOM calls at runtime. The project already has `false` — keep it. - **Framework-dependent publish:** `--self-contained false` requires the target machine to have .NET 10 installed. FOUND-11 requires no runtime dependency. - **Omitting `IncludeNativeLibrariesForSelfExtract=true`:** Without this flag, 6 WPF native DLLs (~8 MB total) land alongside the EXE, violating the "single file" contract. The publish is otherwise identical. - **Using `win-x86` RID:** The app targets 64-bit Windows. Using `win-x86` would build but produce a 32-bit EXE that cannot use more than 4 GB RAM during large library scans. - **Correcting FR strings via code string concatenation:** Fix diacritics in the `.resx` XML file directly. Do not work around them in C# code. The ResourceManager handles UTF-8 correctly. --- ## Don't Hand-Roll | Problem | Don't Build | Use Instead | Why | |---------|-------------|-------------|-----| | Self-contained EXE packaging | Custom bundler / NSIS installer | `dotnet publish --self-contained -p:PublishSingleFile=true` | SDK-native, zero extra tooling | | Native library bundling | Script to ZIP DLLs | `IncludeNativeLibrariesForSelfExtract=true` | SDK handles extraction to `%TEMP%/.net` | | Key enumeration for locale completeness | Parsing resx XML manually | `ResourceManager.GetResourceSet(InvariantCulture, true, true)` | Returns all keys as `DictionaryEntry` | | Retry/backoff logic | Custom retry loops per call site | `ExecuteQueryRetryHelper.ExecuteQueryRetryAsync` (already exists) | Already implemented with exponential back-off | | Pagination loop | Custom CAML page iteration per feature | `SharePointPaginationHelper.GetAllItemsAsync` (already exists) | Already handles `ListItemCollectionPosition` | **Key insight:** Every mechanism needed for Phase 5 already exists in the codebase. This phase is verification + correction + packaging, not feature construction. --- ## Common Pitfalls ### Pitfall 1: WPF Native DLLs Left Outside the Bundle **What goes wrong:** Running `dotnet publish -p:PublishSingleFile=true` without `IncludeNativeLibrariesForSelfExtract=true` leaves `D3DCompiler_47_cor3.dll`, `PenImc_cor3.dll`, `PresentationNative_cor3.dll`, `vcruntime140_cor3.dll`, and `wpfgfx_cor3.dll` as loose files next to the EXE. **Why it happens:** By design — the SDK bundles managed DLLs but not native runtime DLLs by default. **How to avoid:** Always set `true` in the csproj or publish command. **Warning signs:** Publish output folder contains any `.dll` files besides `msalruntime.dll` (WAM broker — bundled with IncludeNativeLibraries). **Verified:** In test publish without the flag: 6 loose DLLs. With the flag: zero loose DLLs (only EXE + PDB). ### Pitfall 2: First-Run Extraction Delay and Antivirus False Positives **What goes wrong:** On the first run on a clean machine, `IncludeNativeLibrariesForSelfExtract` causes the EXE to extract ~8 MB of native DLLs into `%TEMP%\.net\\`. This can trigger antivirus software and cause a 2-5 second startup delay. **Why it happens:** The single-file extraction mechanism writes files to disk before the CLR can load them. **How to avoid:** This is expected behavior. No mitigation needed for this tool. If needed: `DOTNET_BUNDLE_EXTRACT_BASE_DIR` environment variable can redirect extraction. **Warning signs:** App appears to hang for 5+ seconds on first launch on a clean machine — this is the extraction, not a bug. ### Pitfall 3: `PublishTrimmed=true` Silent Runtime Failures **What goes wrong:** PnP.Framework and MSAL use reflection to resolve types at runtime. Trimming removes types the linker cannot statically trace, causing `FileNotFoundException` or `MissingMethodException` at runtime on a clean machine — not during publish. **Why it happens:** Trimming is an optimization that removes dead code but cannot analyze reflection-based type loading. **How to avoid:** The project already has `false`. Never change this. **Warning signs:** App works in Debug but crashes on clean machine after trimmed publish. ### Pitfall 4: French Strings Display as Unaccented Text **What goes wrong:** Phase 4 introduced ~25 FR strings with missing diacritics. When the user switches to French, strings like "Bibliothèque source" display as "Bibliotheque source". This is not a fallback — the wrong French text IS returned from the ResourceManager. **Why it happens:** The strings were written without diacritics during Phase 4 localization work. **How to avoid:** Fix each affected string in `Strings.fr.resx` and add the exhaustive locale completeness test. **Warning signs:** Any FR string containing `e` where `é`, `è`, `ê` is expected. Full list documented in Code Examples section. ### Pitfall 5: Retry Helper Has No Unit Test Coverage **What goes wrong:** If `IsThrottleException` has a bug (wrong string detection), throttled requests will fail without retry. This is invisible without a test. **Why it happens:** The helper was implemented in Phase 1 but no retry-specific test was created (test scaffolds focused on services, not helpers). **How to avoid:** Add `ExecuteQueryRetryHelperTests.cs` with exception classification tests and a live-stub retry loop test. ### Pitfall 6: `Assembly.GetExecutingAssembly().Location` Returns Empty String in Single-File **What goes wrong:** If any code uses `Assembly.Location` to build a file path, it returns `""` in a single-file EXE. **Why it happens:** Official .NET single-file limitation. **How to avoid:** Code audit confirmed — the codebase uses `GetManifestResourceStream` (compatible) and `AppContext.BaseDirectory` (compatible). No `Assembly.Location` usage found. **Warning signs:** Any path construction using `Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location)`. --- ## Code Examples ### Single-File Publish csproj Configuration ```xml true true win-x64 true ``` ### Verified Publish Command ```bash # Run from SharepointToolbox/ project directory # Produces: ./publish/SharepointToolbox.exe (~201 MB) + SharepointToolbox.pdb dotnet publish -c Release -r win-x64 --self-contained true \ -p:PublishSingleFile=true \ -p:IncludeNativeLibrariesForSelfExtract=true \ -o ./publish ``` ### FR Strings Requiring Diacritic Correction The following strings in `Strings.fr.resx` have incorrect values (missing accents). Each line shows: key → current wrong value → correct French value: ``` transfer.sourcelibrary "Bibliotheque source" → "Bibliothèque source" transfer.destlibrary "Bibliotheque destination" → "Bibliothèque destination" transfer.mode.move "Deplacer" → "Déplacer" transfer.conflict.overwrite "Ecraser" → "Écraser" transfer.start "Demarrer le transfert" → "Démarrer le transfert" transfer.nofiles "Aucun fichier a transferer" → "Aucun fichier à transférer." bulkmembers.preview "Apercu (...)" → "Aperçu (...)" bulksites.execute "Creer les sites" → "Créer les sites" bulksites.preview "Apercu (...)" → "Aperçu (...)" bulksites.owners "Proprietaires" → "Propriétaires" folderstruct.execute "Creer les dossiers" → "Créer les dossiers" folderstruct.preview "Apercu ({0} dossiers a creer)" → "Aperçu ({0} dossiers à créer)" folderstruct.library "Bibliotheque cible" → "Bibliothèque cible" templates.list "Modeles enregistres" → "Modèles enregistrés" templates.opt.libraries "Bibliotheques" → "Bibliothèques" templates.opt.folders (ok: "Dossiers") templates.opt.permissions (ok: "Groupes de permissions") bulk.confirm.proceed "Continuer" → OK (no diacritic needed) bulk.result.success "Termine : {0} reussis, {1} echoues" → "Terminé : {0} réussis, {1} échoués" bulk.result.allfailed "Les {0} elements ont echoue." → "Les {0} éléments ont échoué." bulk.result.allsuccess "Les {0} elements ont ete traites avec succes." → "Les {0} éléments ont été traités avec succès." bulk.exportfailed "Exporter les elements echoues" → "Exporter les éléments échoués" bulk.retryfailed "Reessayer les echecs" → "Réessayer les échecs" bulk.validation.invalid "{0} lignes contiennent des erreurs. Corrigez et reimportez." → "...réimportez." bulk.csvimport.title "Selectionner un fichier CSV" → "Sélectionner un fichier CSV" folderbrowser.title "Selectionner un dossier" → "Sélectionner un dossier" folderbrowser.select "Selectionner" → "Sélectionner" ``` **Note on `templates.*` keys:** Several templates keys also lack accents: `templates.capture "Capturer un modele"` → `"Capturer un modèle"`, `templates.apply "Appliquer le modele"` → `"Appliquer le modèle"`, `templates.name "Nom du modele"` → `"Nom du modèle"`, etc. ### ExecuteQueryRetryHelper — `IsThrottleException` Unit Test Pattern ```csharp // Source: established project test pattern (InternalsVisibleTo + internal static) // Make IsThrottleException internal static in ExecuteQueryRetryHelper [Theory] [InlineData("The request has been throttled — 429")] [InlineData("Service unavailable 503")] [InlineData("SharePoint has throttled your request")] public void IsThrottleException_ReturnsTrueForThrottleMessages(string message) { var ex = new Exception(message); Assert.True(ExecuteQueryRetryHelper.IsThrottleException(ex)); } [Fact] public void IsThrottleException_ReturnsFalseForNonThrottleException() { var ex = new Exception("File not found"); Assert.False(ExecuteQueryRetryHelper.IsThrottleException(ex)); } ``` ### SharePointPaginationHelper — `BuildPagedViewXml` Unit Test Pattern ```csharp // Make BuildPagedViewXml internal static [Fact] public void BuildPagedViewXml_EmptyInput_ReturnsViewWithRowLimit() { var result = SharePointPaginationHelper.BuildPagedViewXml(null, rowLimit: 2000); Assert.Equal("2000", result); } [Fact] public void BuildPagedViewXml_ExistingRowLimit_Replaces() { var input = "100"; var result = SharePointPaginationHelper.BuildPagedViewXml(input, rowLimit: 2000); Assert.Equal("2000", result); } ``` --- ## State of the Art | Old Approach | Current Approach | When Changed | Impact | |--------------|------------------|--------------|--------| | Separate installer (MSI/NSIS) | Single-file self-contained EXE | .NET 5+ | No installer needed — xcopy deployment | | Framework-dependent deploy | Self-contained deploy | .NET Core 3.1+ | No runtime prereq on target machine | | Native DLLs always loose | `IncludeNativeLibrariesForSelfExtract=true` | .NET 5+ | True single-EXE for WPF | | Manual publish profile | `` in csproj | .NET 5+ | Reproducible via `dotnet publish` | **Deprecated/outdated:** - ClickOnce: Still exists but not used here — requires IIS/file share hosting, adds update mechanism this project doesn't need. - MSIX packaging: Requires Developer Mode or certificate signing — overkill for an admin tool distributed by the developer directly. --- ## Open Questions 1. **PDB embedding vs. separate file** - What we know: `embedded` merges PDB into the EXE; `none` strips symbols entirely. - What's unclear: User preference for crash debugging — does the admin want to be able to debug crashes post-deploy? - Recommendation: Default to keeping the separate PDB (current behavior). If release packaging requires it, `none` is the correct property. 2. **WAM broker (`msalruntime.dll`) on clean machines** - What we know: With `IncludeNativeLibrariesForSelfExtract=true`, `msalruntime.dll` is bundled into the EXE and extracted on first run. The interactive MSAL login (browser/WAM) uses this DLL. - What's unclear: Whether WAM authentication works correctly when the runtime is extracted vs. being a loose file. Not testable without a clean machine. - Recommendation: Flag for human smoke test — launch on a clean Windows 10/11 machine, authenticate to a tenant, verify the browser/WAM login flow completes. 3. **`EnableCompressionInSingleFile` tradeoff** - What we know: Setting this true reduces EXE size (201 MB → potentially ~130 MB) but adds ~1-3 second startup decompression delay. - What's unclear: User tolerance for startup delay vs. distribution size. - Recommendation: Do not set — the 201 MB EXE is acceptable for an admin tool. Compression adds startup complexity with minimal distribution benefit for this use case. --- ## Validation Architecture ### Test Framework | Property | Value | |----------|-------| | Framework | xUnit 2.9.3 | | Config file | SharepointToolbox.Tests/SharepointToolbox.Tests.csproj | | Quick run command | `dotnet test SharepointToolbox.Tests/SharepointToolbox.Tests.csproj --no-build -v quiet` | | Full suite command | `dotnet test SharepointToolbox.Tests/SharepointToolbox.Tests.csproj -v quiet` | ### Phase Requirements → Test Map | Req ID | Behavior | Test Type | Automated Command | File Exists? | |--------|----------|-----------|-------------------|-------------| | FOUND-11-a | `PublishSingleFile` + `IncludeNativeLibrariesForSelfExtract` produces single EXE | smoke (manual) | `dotnet publish -c Release -r win-x64 --self-contained true -p:PublishSingleFile=true -p:IncludeNativeLibrariesForSelfExtract=true -o /tmp/pub && ls /tmp/pub/*.dll \| wc -l` (expect 0) | ❌ Wave 0 | | FOUND-11-b | App launches on clean machine with no .NET runtime | manual smoke | N/A — requires clean VM | manual-only | | SC-2-retry | `ExecuteQueryRetryHelper.IsThrottleException` classifies 429/503 correctly | unit | `dotnet test --filter "ExecuteQueryRetryHelper"` | ❌ Wave 0 | | SC-2-retry | Retry loop reports progress and eventually throws after MaxRetries | unit | `dotnet test --filter "ExecuteQueryRetryHelper"` | ❌ Wave 0 | | SC-3-fr | All 177 EN keys have non-empty, non-bracketed FR values | unit | `dotnet test --filter "LocaleCompleteness"` | ❌ Wave 0 | | SC-3-fr | No diacritic-missing strings appear when language=FR | manual smoke | N/A — visual inspection | manual-only | | SC-4-paginat | `BuildPagedViewXml` correctly injects and replaces RowLimit | unit | `dotnet test --filter "SharePointPagination"` | ❌ Wave 0 | | SC-4-paginat | Scan against 5,000+ item library returns complete results | manual smoke | N/A — requires live tenant | manual-only | ### Sampling Rate - **Per task commit:** `dotnet test SharepointToolbox.Tests/SharepointToolbox.Tests.csproj --no-build -v quiet` - **Per wave merge:** `dotnet test SharepointToolbox.Tests/SharepointToolbox.Tests.csproj -v quiet` - **Phase gate:** Full suite green before `/gsd:verify-work` + manual smoke checklist ### Wave 0 Gaps - [ ] `SharepointToolbox.Tests/Services/ExecuteQueryRetryHelperTests.cs` — covers SC-2-retry - [ ] `SharepointToolbox.Tests/Services/SharePointPaginationHelperTests.cs` — covers SC-4-paginat - [ ] `SharepointToolbox.Tests/Localization/LocaleCompletenessTests.cs` — covers SC-3-fr - [ ] `ExecuteQueryRetryHelper.IsThrottleException` must be changed from `private static` to `internal static` - [ ] `SharePointPaginationHelper.BuildPagedViewXml` must be changed from `private static` to `internal static` - [ ] `InternalsVisibleTo("SharepointToolbox.Tests")` already in `AssemblyInfo.cs` — no change needed --- ## Sources ### Primary (HIGH confidence) - Official .NET docs: https://learn.microsoft.com/en-us/dotnet/core/deploying/single-file/overview — single-file publish behavior, API incompatibilities, `IncludeNativeLibrariesForSelfExtract` - Live publish test (2026-04-03): ran `dotnet publish` on actual codebase — confirmed single EXE output with and without `IncludeNativeLibrariesForSelfExtract` - Direct code inspection: `ExecuteQueryRetryHelper.cs`, `SharePointPaginationHelper.cs`, `Strings.resx`, `Strings.fr.resx`, `SharepointToolbox.csproj` ### Secondary (MEDIUM confidence) - Microsoft Q&A: https://learn.microsoft.com/en-us/answers/questions/990342/wpf-publishing-application-into-single-exe-file — confirmed WPF native DLL bundling behavior - MSAL WAM docs: https://learn.microsoft.com/en-us/entra/msal/dotnet/acquiring-tokens/desktop-mobile/wam — msalruntime.dll behavior with broker ### Tertiary (LOW confidence) - None --- ## Metadata **Confidence breakdown:** - Standard stack: HIGH — publish tested live against actual codebase - Architecture: HIGH — based on direct code inspection + official docs - Pitfalls: HIGH — pitfall 1 verified experimentally; others from official docs + established project patterns - FR string gaps: HIGH — direct inspection of Strings.fr.resx, enumerated 25+ affected keys **Research date:** 2026-04-03 **Valid until:** 2026-07-03 (stable .NET tooling; 90 days reasonable)