From e3ffc9859b38fbb0276120d5d1b7ffb43e5cea1d Mon Sep 17 00:00:00 2001 From: Satwik Kumar Sharma Date: Tue, 13 Oct 2026 14:15:07 +0530 Subject: [PATCH 01/10] Add vm-component-coverage skill for local VM code coverage Adds an experimental agent skill that calculates code coverage of a WinUI control/component by running only its tests on a local Hyper-V VM against the local build. Prompting "calculate coverage of control " resolves the control under controls\dev (or investigates dxaml and gets user approval), gates on user-confirmed VM details, then instruments, runs, restores and reports ADO-accurate (deduplicated) per-file and per-function coverage. - Step scripts and lib\ are copied unmodified from the validated standalone skill. - Invoke-WinUIVmCoverage.ps1: -TestFilter accepts multiple filters (one collector run each, merged in the report); single-filter behavior unchanged. - Set-VmCredential.ps1: validates the VM, takes the password in a separate window (never in chat), writes the cache run-tests-on-vm.ps1 uses, and checks logon plus an active desktop session. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: fcbd0fa8-6888-4e0c-96aa-0c60b83530e6 --- .github/skills/vm-component-coverage/SKILL.md | 239 ++++++++ .../scripts/Invoke-WinUIVmCoverage.ps1 | 97 ++++ .../scripts/Set-VmCredential.ps1 | 75 +++ .../scripts/Step1-New-InstrumentableDll.ps1 | 121 ++++ .../scripts/Step2-Instrument-Payload.ps1 | 142 +++++ .../scripts/Step3-Invoke-VmCoverageRun.ps1 | 193 +++++++ .../scripts/Step4-Restore-Payload.ps1 | 69 +++ .../scripts/Step5-Get-ComponentCoverage.ps1 | 155 +++++ .../scripts/lib/Build-CoverageTreeReport.ps1 | 455 +++++++++++++++ .../lib/Build-FunctionCoverageTreeReport.ps1 | 540 ++++++++++++++++++ .../scripts/lib/Get-FunctionCoverage.ps1 | 350 ++++++++++++ .../scripts/lib/Repair-CoverageDuplicates.ps1 | 231 ++++++++ .../scripts/lib/Set-CoveragePipeAcl.ps1 | 84 +++ .../scripts/lib/Split-CoverageByModule.ps1 | 255 +++++++++ 14 files changed, 3006 insertions(+) create mode 100644 .github/skills/vm-component-coverage/SKILL.md create mode 100644 .github/skills/vm-component-coverage/scripts/Invoke-WinUIVmCoverage.ps1 create mode 100644 .github/skills/vm-component-coverage/scripts/Set-VmCredential.ps1 create mode 100644 .github/skills/vm-component-coverage/scripts/Step1-New-InstrumentableDll.ps1 create mode 100644 .github/skills/vm-component-coverage/scripts/Step2-Instrument-Payload.ps1 create mode 100644 .github/skills/vm-component-coverage/scripts/Step3-Invoke-VmCoverageRun.ps1 create mode 100644 .github/skills/vm-component-coverage/scripts/Step4-Restore-Payload.ps1 create mode 100644 .github/skills/vm-component-coverage/scripts/Step5-Get-ComponentCoverage.ps1 create mode 100644 .github/skills/vm-component-coverage/scripts/lib/Build-CoverageTreeReport.ps1 create mode 100644 .github/skills/vm-component-coverage/scripts/lib/Build-FunctionCoverageTreeReport.ps1 create mode 100644 .github/skills/vm-component-coverage/scripts/lib/Get-FunctionCoverage.ps1 create mode 100644 .github/skills/vm-component-coverage/scripts/lib/Repair-CoverageDuplicates.ps1 create mode 100644 .github/skills/vm-component-coverage/scripts/lib/Set-CoveragePipeAcl.ps1 create mode 100644 .github/skills/vm-component-coverage/scripts/lib/Split-CoverageByModule.ps1 diff --git a/.github/skills/vm-component-coverage/SKILL.md b/.github/skills/vm-component-coverage/SKILL.md new file mode 100644 index 000000000..9702a81e6 --- /dev/null +++ b/.github/skills/vm-component-coverage/SKILL.md @@ -0,0 +1,239 @@ +--- +name: vm-component-coverage +description: >- + EXPERIMENTAL. Calculate code coverage of a WinUI control or component (for example + "calculate coverage of control TitleBar") by running only that component's tests on a local + Hyper-V VM against the local build. Reports per-file and per-function line coverage that matches + the ADO Code Coverage tab. Use for "calculate/measure/get coverage of ", "how much do + 's tests cover", or "coverage before/after my tests" when you have a local build and a VM. +--- + +# WinUI component coverage from a local VM test run + +## Agent workflow (follow in order; every gate is mandatory) + +All commands run from the repo root. `` = `.github\skills\vm-component-coverage`. + +### Gate 0 - Prerequisites (check; stop and tell the user on failure) + +| Check | How | If missing | +|---|---|---| +| Local build of the flavor (default `amd64chk`) | `BuildOutput\bin\amd64chk\Product\Microsoft.UI.Xaml.Controls.dll` and `BuildOutput\obj\amd64chk\controls\dev\dll\**\link.command.1.tlog` exist | Follow the **build** skill. Ask before starting a full build (10+ min). | +| VS Enterprise code coverage | `Microsoft.CodeCoverage.Console.exe` under `%ProgramFiles%\Microsoft Visual Studio` | Stop; the user must install it. | +| `dotnet-coverage` | `Get-Command dotnet-coverage` | Tell the user: `dotnet tool install --global dotnet-coverage` | +| Hyper-V rights | `Get-VM` succeeds | Follow the permission steps in the **test-on-vm** skill. Never elevate or add group members yourself. | + +### Gate 1 - VM details (strict: never start a run without them) + +**Before every coverage run, the user must explicitly confirm the VM or enter its details in this +session.** Never pick a VM silently, even if only one credential is cached. Never reuse a VM name, +username or password from memory, an earlier session, or an earlier run without asking again. + +1. Look for cached credentials. This shows the VM file and username only, never the password: + ```powershell + Get-ChildItem "$env:USERPROFILE\.winui-test\vmcred-*.xml" -EA SilentlyContinue | + ForEach-Object { [pscustomobject]@{ VM = $_.BaseName -replace '^vmcred-'; UserName = (Import-Clixml $_.FullName).UserName } } + ``` +2. Use `ask_user` every time: + - **Cached credentials exist:** ask "Is this the VM to use?" Offer one choice per cached VM and + username, plus **"Enter different VM details"**. + - **No cache, or the user chooses different details:** ask for the **VM name** and **VM username** + (the plain account name as shown on the VM, with no `.\` or `VM\` prefix). + - If the user declines or cancels, stop. Do not continue with a default. +3. Run `\scripts\Set-VmCredential.ps1 -VMName -UserName `: + - For new details, add `-Force`. The script asks for the **password in a separate window** (never in + chat; chat text is kept in session history) and writes the encrypted per-user cache + `%USERPROFILE%\.winui-test\vmcred-.xml`. This is the cache `tools\run-tests-on-vm.ps1` and Steps 3-4 read; + Step 3 does not pass `-Credential` to the runner, so the cache must exist. + - For a confirmed cached VM, run it without `-Force`. It validates without prompting. + - Either way it checks that the VM exists and is Running, that the logon works, and that the user has an + Active desktop session. A new credential replaces the cache only after it validates. +4. If the script fails (VM missing or stopped, bad password, no active desktop), report the message, + ask the user to fix it or give new details, and repeat this gate. Do not continue. + +### Gate 2 - Resolve the component and its tests + +**A. `controls\dev` (MUXC, `Microsoft.UI.Xaml.Controls.dll`)** + +1. Folder: match the name case-insensitively against `controls\dev\*` folders. If there's no match, + search `controls\dev\*\*.idl` for `runtimeclass ` and use that folder (for example + `ItemsRepeater` resolves to `Repeater`). If there are zero or several candidates, ask the user and offer the candidates. +2. Tests: list the `[TestClass]` classes (with their namespaces) in `controls\dev\\APITests\**\*.cs` + and `controls\dev\\InteractionTests\**\*.cs`. TAEF test names are `..`, + and `runtests` selects `@Name='*'`. + - Prefer one wildcard that covers every class, usually `*.Tests.*`. It covers both the `ApiTests` + and `InteractionTests` classes, and namespaces such as `...ApiTests.RepeaterTests.*`. + - If no single wildcard covers them all, pass several filters (`-TestFilter 'a','b'`). Each runs + with its own collector, and the results are merged and deduplicated in the report. + - If the folder has no tests, tell the user and ask which tests to run. +3. `-ComponentPathRegex`: `\\controls\\dev\\\\` plus `\\controls\\dev\\Generated\\(||...)\.` + for each `runtimeclass` in `controls\dev\\*.idl` that has a generated file in `controls\dev\Generated`. +4. Show the resolved plan (folder, test classes, filter(s), regex), then proceed. Ask only if + something is ambiguous. + +**B. Not in `controls\dev`: investigate `dxaml` (MUX, `Microsoft.ui.xaml.dll`) and get approval** + +1. Sources: search `dxaml\xcp` for the type, for example `core\core\elements\.cpp`, `core\inc\.h`, + `dxaml\lib\_Partial.*`, `dxaml\lib\winrtgeneratedclasses\.g.*`, `components\**`, and + `core\controls\generated\C*`. Decide which are in scope (for example whether `ButtonBase` counts for `Button`). +2. Tests: `dxaml\test\native\external\\\` (C++ TAEF; names look like + `Microsoft::UI::Xaml::Tests::Controls::::::`). Note each class's `Hosting:Mode`; + `runtests` cannot mix hosting modes in one filter, so use one filter per mode. +3. Use `ask_user` to present the proposed source files and regex, test classes and filter(s), and module + `Microsoft.ui.xaml.dll`. Warn that MUX is about 71 MB, so the first instrumentation is likely to take + well over 40 minutes. **Do not run until the user approves.** Revise the plan with their feedback. + +**C. Found in neither place:** ask the user which source folder and tests to use. + +### Run + +- Output: `\CodeCoverageOutput\` (already ignored by `.gitignore`). +- Add `-RefreshPayload` if `TestPayload\x64chk` is missing or older than the build output. +- Add `-MoveSessionToConsole` when the host is an RDP or Cloud PC session (`$env:SESSIONNAME -like 'RDP-*'`). +- Tell the user the expected time first (about 30-45 min the first time for MUXC, about 6 min with the cache). + Run it async with a long wait. **Never stop the process during Step 2 or 3**; the restore runs in `finally`. + +Run from the repo root in PowerShell, invoking the script directly. Don't use `pwsh -File`: it turns +`'a','b'` into a single string. + +```powershell +& .\.github\skills\vm-component-coverage\scripts\Invoke-WinUIVmCoverage.ps1 ` + -RepoRoot (Get-Location).Path -VMName -TestFilter ''[,''] ` + -ComponentName -ComponentPathRegex '' -Module ` + -OutputDir .\CodeCoverageOutput\ [-RefreshPayload] [-MoveSessionToConsole] +``` + +### Report + +- Confirm Step 4 restored the payload. If the log shows `RESTORE FAILED`, tell the user prominently. +- From `report-\coverage-summary.md`, present the component total, the per-file table, and the + uncovered and partially covered functions. Point to `TreeReport\coverage-tree.html` and the CSVs. +- Check the test summary. If `Total=0`, the filter matched nothing; fix the filter instead of reporting 0 %. + +--- + +# Reference (original skill documentation) + +Measures **which lines of one component your selected tests execute**. It doesn't need a pipeline +run: it uses your local build and the same Hyper-V VM and runner (`tools\run-tests-on-vm.ps1`) you +already use for WinUI tests. + +It's built from three references: + +| Reference | What was reused | +|---|---| +| `microsoft-ui-xaml-lift` PR **16435603** ("Add focused code coverage pipeline") | How to make MUXC instrumentable (`/debugtype:cv,fixup` + `/INCREMENTAL:NO`, commit `2919de62`). Static instrumentation with a per-module session id, broadcast to every payload copy plus `static_covrun*.dll`. A server-mode collector with a NULL-DACL pipe (`Set-CoveragePipeAcl.ps1`, copied unmodified into `scripts\lib`). Shutdown to flush. | +| `WinUITestInventory\SkillToGetCodeCoverageFromPipeline` | The (file, line) dedup fix that makes numbers match the ADO Code Coverage tab (`Split-CoverageByModule.ps1`), the HTML tree report, and function-level coverage. These scripts are copied **unmodified** into `scripts\lib`. | +| `WinUITestInventory\CoverageReportFromScripts` | The same merge flow (`dotnet-coverage merge` to Cobertura), and the note that the PDB is only needed at instrument time. | + +## Quick start + +```powershell +cd \.github\skills\vm-component-coverage\scripts +.\Invoke-WinUIVmCoverage.ps1 ` + -RepoRoot ` + -VMName ` + -TestFilter '*.TitleBarTests.*' ` + -ComponentName TitleBar ` + -ComponentPathRegex '\\controls\\dev\\TitleBar\\|\\Generated\\TitleBar|\\generated\\Controls\\TitleBar' ` + -OutputDir \CodeCoverageOutput\TitleBar ` + -MoveSessionToConsole ` + -BaselinePerFileCsv '\PerModule\Microsoft.UI.Xaml.Controls\corrected-per-file.csv' ` + -BaselineLabel 'CI build 157731159 (all tests)' +``` + +Open `\report-\coverage-summary.md`. Other outputs in that folder: +- `component-per-file.csv` +- `component-functions.csv` +- `TreeReport\coverage-tree.html` +- `PerModule\\corrected-per-file.csv` (whole module) + +## Prerequisites + +- **Local build:** a full local build of the flavor you test (default `amd64chk`), so that `BuildOutput\obj\...\link.command.1.tlog`, the objs and the PDB exist. Also an existing `TestPayload\x64chk`, created by any earlier `tools\run-tests-on-vm.ps1` run, or pass `-RefreshPayload`. +- **Visual Studio Enterprise (host):** supplies `Microsoft.CodeCoverage.Console.exe` for instrument, collect and shutdown, and the MSVC toolset that built the objs. +- **`dotnet-coverage`:** `dotnet tool install --global dotnet-coverage`, used for the merge. +- **VM access:** + - A running Hyper-V VM with the test user logged on, an unlocked interactive desktop, and the WinUI test prerun already done (any earlier `run-tests-on-vm.ps1` run does this). + - The host account must be a local admin or a member of Hyper-V Administrators. + - The VM credential must be cached in `%USERPROFILE%\.winui-test\vmcred-.xml`, the same cache `run-tests-on-vm.ps1` uses. +- **No VS or dotnet needed on the VM:** the collector tools are staged to `C:\WinUICoverage\CoverageTool` automatically. + +## What it does (and why) + +| Step | Script | Notes | +|---|---|---| +| 1 | `Step1-New-InstrumentableDll.ps1` | **MUXC only.** chk MUXC is not instrumentable: the tool prints "*Unable to obtain debug information. Link with the /PROFILE linker switch.*" and then **exits 0** with the image untouched. The lift PR fixes this with an opt-in `EnableCodeCoverageLinkOptions` MSBuild property, which this checkout may not have, and repo files must not be edited. So the script re-runs the **exact link command MSBuild recorded** (`link.command.1.tlog`) with `/DEBUGTYPE:CV,FIXUP` added and `/INCREMENTAL:NO`. `/OUT`, `/PDB`, `/IMPLIB` and `/MAP` are redirected to `work\relink`, and `/WINMD:NO` is set. It takes about 15 s, and BuildOutput is never touched. MUX (`Microsoft.ui.xaml.dll`) already links with `/debugtype:cv,fixup,pdata` and skips this step. | +| 2 | `Step2-Instrument-Payload.ps1` | Static native instrumentation with a fresh 16-hex session id. **This is slow for a 38 MB chk DLL (about 20 min)**, so the result is cached in `work\instrumented`, keyed by the input DLL's SHA256, and reused on later runs together with its baked-in session id. The script catches the "exit 0 but not instrumented" case. It backs up and then overwrites **every** payload copy of the DLL, plus `static_covrun64.dll`, because each test app loads its own co-located copy (`Test\UnpackagedApps\MUXControlsTestApp\`). | +| 3 | `Step3-Invoke-VmCoverageRun.ps1` | Stages the collector on the VM once. It starts `collect --server-mode --session-id ` as the VM user through an interactive scheduled task, waits for `\\.\pipe\CodeCoverage.pipe.`, and sets a NULL DACL on it. It then runs `initrun.ps1 .\tools\run-tests-on-vm.ps1 -SkipPayload `, which deploys the instrumented payload and runs the tests on the desktop. In `finally` it calls `shutdown ` so the collector writes the `.coverage` file, then downloads it. | +| 4 | `Step4-Restore-Payload.ps1` | Always runs, from `finally`. It restores every original DLL, hash-verified, removes `static_covrun*.dll`, and does the same in the VM's deployed payload. Each distinct original is restored to its own copies. | +| 5 | `Step5-Get-ComponentCoverage.ps1` | Merges the coverage to Cobertura (`dotnet-coverage merge -f cobertura`) and applies the per-module (file, line) dedup (`lib\Split-CoverageByModule.ps1`), the same correction that matches the ADO tab. It then filters to the component by `-ComponentPathRegex` and optionally compares with a baseline `corrected-per-file.csv`. Paths are matched repo-relative, so `C:\__w\1\s\...` versus `C:\Repos\...` and `x86chk` versus `amd64chk` don't matter. It also produces function-level coverage (`lib\Get-FunctionCoverage.ps1`) and the HTML tree (`lib\Build-CoverageTreeReport.ps1`). | + +You can run the steps one at a time. Each writes its state or results under `\work`. + +## Timing + +- **First run:** about 30-45 min, almost all of it Step 2 instrumenting a 38 MB chk MUXC. The rest is a few seconds to relink, then deploy, the tests, flush and the report. +- **Later runs with an unchanged build:** about 6 min. The relink and the instrumented DLL are both reused from `\work`. +- **Report:** parsing the Cobertura and native XML takes about 1-2 min each; skip the second with `-SkipFunctionCoverage`. + +## Verified result (TitleBar, 2026-10-06) + +`-TestFilter '*.TitleBarTests.*'` on a local Hyper-V VM (Win11 26688, amd64chk): 24/24 tests passed, and the collector produced an 88 MB `.coverage` file. These are the numbers from the full skill run: + +| File | TitleBar tests only (this skill) | CI build 157731159 (all tests, pipeline skill) | +|---|---|---| +| `controls\dev\TitleBar\TitleBar.cpp` | **98.07 %** (507/517) | 10.64 % (55/517) | +| `controls\dev\TitleBar\TitleBarAutomationPeer.cpp` | **100 %** (17/17) | 0 % (0/17) | +| `controls\dev\Generated\TitleBar.properties.cpp` | **90.23 %** (157/174) | 28.74 % (50/174) | +| All 15 TitleBar source files | **81.03 %** (773/954) | 23.69 % (226/954) | + +Of 45 functions in the TitleBar `.cpp` files, 40 are fully covered, 4 partially, and 1 not at all (`OnWindowRectChanged`). Afterwards, the payload, the VM and BuildOutput were all verified back at their original hashes. + +## Gotchas (learned the hard way) + +- **Host session disconnects (Cloud PC / RDP):** if the host's session disconnects, the Hyper-V *enhanced session* stops delivering input. UI tests then fail with `InjectMouseInput ... Access is denied` and "Test window lost focus". Use `-MoveSessionToConsole`, which runs `tscon /dest:console` as SYSTEM inside the VM, so the VM desktop stays interactive. Reconnecting with vmconnect moves the session back. +- **Instrumenting is slow:** about 20 min and about 3 GB RAM for chk MUXC. Don't interrupt it; later runs reuse the cache. Use `-ForceReinstrument` after a rebuild if the hash didn't change but you want a new session id. +- **Session id must match:** the DLL the test process loads must carry the collector's session id. If the coverage file is tiny or empty, check `work\raw\\collector.log` and confirm the app-local copy (`MUXControlsTestApp\Microsoft.UI.Xaml.Controls.dll`) is the instrumented one. +- **Rebuilds overwrite the instrumented copy:** rebuilding `MUXControlsTestApp` re-stages an older Controls DLL into `BuildOutput\...\UnpackagedApps\MUXControlsTestApp`. That doesn't matter here because Step 2 overwrites the *payload* copies, but don't run `run-tests-on-vm.ps1` **without** `-SkipPayload` in between, because that recreates the payload. +- **Expected failures still count toward coverage:** failing or ignored tests still execute product code until they fail, and Ignored tests don't run at all. +- **Known crash at exit (PC-3) is harmless:** TitleBar runs fail-fast at app exit, which shows as cleanup errors. Coverage still flushes because the collector is out of process. +- **Numbers differ from CI:** a local VM run uses a different OS build, flavor (x64 vs CI's x86) and test subset. Compare per file, not the module total. +- **NULL DACL:** the collector pipe is open to every local user until shutdown. That's fine on a test VM, but avoid it on shared machines. +- **`cmd /c` quoting:** starting the collector via `cmd /c "" ... > ""` silently fails, because cmd strips the outer quotes. Step 3 therefore writes a `.cmd` wrapper. If the collector pipe never appears, check that wrapper and `C:\WinUICoverage\out\collector-.log` on the VM. +- **Same tool for instrument and collect:** the collector must be the same `Microsoft.CodeCoverage.Console.exe` version that instrumented the DLL. Step 2 records the tool, and Step 3 stages that exact version to `C:\WinUICoverage\CoverageTool\`. When several VS installs exist, the highest file version wins; path string order is not used. +- **Relink is not byte-deterministic:** Step 1 therefore reuses its previous output while the recorded link inputs are unchanged. Otherwise the Step 2 cache, keyed by DLL hash, would miss and re-instrument for 45 minutes. +- **Single-file bug in `lib\Get-FunctionCoverage.ps1`:** with a single `.coverage` input, the script's own merge produces an empty ``. An if-expression unwraps the one-element array, so `@inputFiles` splats nothing. Step 5 works around this by pre-merging (`dotnet-coverage merge -f xml`) and calling the script with `-SkipMerge`. The copied script is left unmodified. +- **Host suspends pause the run:** a Cloud PC host that sleeps or suspends pauses instrumentation; it resumes and finishes afterwards. Keep the host awake for predictable timing. + +## Parameters (`Invoke-WinUIVmCoverage.ps1`) + +| Parameter | Default | Notes | +|---|---|---| +| `-RepoRoot` | required | WinUI checkout with a completed local build | +| `-VMName` | required | Hyper-V VM | +| `-TestFilter` | required | One or more filters passed to `run-tests-on-vm.ps1`, e.g. `*.TitleBarTests.*`. With several, Step 3 runs once per filter and Step 5 merges the `.coverage` files. | +| `-OutputDir` | required | Work and report root | +| `-ComponentName` / `-ComponentPathRegex` | TitleBar | Regex on the source path selecting the component's files | +| `-Module` | `Microsoft.UI.Xaml.Controls.dll` | or `Microsoft.ui.xaml.dll` | +| `-Flavor` | `amd64chk` | Build flavor; payload `x64chk` | +| `-BaselinePerFileCsv` / `-BaselineLabel` | none | e.g. a CI `corrected-per-file.csv` from the pipeline skill | +| `-MoveSessionToConsole` | off | Recommended on Cloud PC / RDP hosts | +| `-RefreshPayload` | off | Run `test\CreateTestPayload.ps1` first | +| `-ForceReinstrument` | off | Ignore the instrumented-DLL cache | +| `-SkipFunctionCoverage` | off | Skip the function-level merge | + +## Files in this skill + +| File | Purpose | +|---|---| +| `scripts\Invoke-WinUIVmCoverage.ps1` | One-command orchestrator for Steps 1-5. Step 3 runs once per `-TestFilter`. The payload and VM are restored in `finally`. | +| `scripts\Step1-New-InstrumentableDll.ps1` | Relinks MUXC with `/DEBUGTYPE:CV,FIXUP` from the recorded link command, outside BuildOutput. | +| `scripts\Step2-Instrument-Payload.ps1` | Static instrumentation (cached) and broadcast into `TestPayload\`, with backups. | +| `scripts\Step3-Invoke-VmCoverageRun.ps1` | Starts the collector on the VM, runs the tests through `tools\run-tests-on-vm.ps1 -SkipPayload`, shuts the collector down and downloads the coverage file. | +| `scripts\Step4-Restore-Payload.ps1` | Restores the payload and the VM copies, hash-verified. | +| `scripts\Set-VmCredential.ps1` | Gate 1 helper: validates the VM, takes the password in a separate window, writes the encrypted credential cache, and checks the logon and the active desktop. | +| `scripts\Step5-Get-ComponentCoverage.ps1` | Merge, per-module dedup, component filter, baseline comparison, function coverage, HTML tree and `coverage-summary.md`. | +| `scripts\lib\Set-CoveragePipeAcl.ps1` | Unmodified copy from lift PR 16435603 (`Helix\common\pipeline\coverage\`). | +| `scripts\lib\Split-CoverageByModule.ps1`, `Repair-CoverageDuplicates.ps1`, `Build-CoverageTreeReport.ps1`, `Get-FunctionCoverage.ps1`, `Build-FunctionCoverageTreeReport.ps1` | Unmodified copies from `WinUITestInventory\SkillToGetCodeCoverageFromPipeline\scripts`. | + diff --git a/.github/skills/vm-component-coverage/scripts/Invoke-WinUIVmCoverage.ps1 b/.github/skills/vm-component-coverage/scripts/Invoke-WinUIVmCoverage.ps1 new file mode 100644 index 000000000..090525940 --- /dev/null +++ b/.github/skills/vm-component-coverage/scripts/Invoke-WinUIVmCoverage.ps1 @@ -0,0 +1,97 @@ +<# +.SYNOPSIS + One command: code coverage of a WinUI component from ONLY the tests you choose, run on a local + Hyper-V VM. + +.DESCRIPTION + relink (instrumentable copy) -> instrument + broadcast into TestPayload -> collector + tests on VM + -> download .coverage -> restore payload/VM -> component report (optionally vs. a CI baseline). + The payload and the VM are restored in a finally block even if the run fails. + +.EXAMPLE + # TitleBar coverage from the TitleBar tests only, compared to CI build 157731159's numbers + .\Invoke-WinUIVmCoverage.ps1 -RepoRoot C:\Repos\WinUiGitHubRepo\microsoft-ui-xaml -VMName WinUiTestVM1 ` + -TestFilter '*.TitleBarTests.*' -ComponentName TitleBar ` + -ComponentPathRegex '\\controls\\dev\\TitleBar\\|\\Generated\\TitleBar|\\generated\\Controls\\TitleBar' ` + -OutputDir C:\cov\TitleBar -MoveSessionToConsole ` + -BaselinePerFileCsv C:\...\CodeCoverageOutput_157731159\PerModule\Microsoft.UI.Xaml.Controls\corrected-per-file.csv +#> +[CmdletBinding()] +param( + [Parameter(Mandatory)][string]$RepoRoot, + [Parameter(Mandatory)][string]$VMName, + # One or more runtests filters; each runs with its own collector session, then all are merged. + [Parameter(Mandatory)][string[]]$TestFilter, + [Parameter(Mandatory)][string]$OutputDir, + [string]$ComponentName = 'TitleBar', + [string]$ComponentPathRegex = '\\controls\\dev\\TitleBar\\|\\Generated\\TitleBar|\\generated\\Controls\\TitleBar', + [ValidateSet('Microsoft.UI.Xaml.Controls.dll', 'Microsoft.ui.xaml.dll')][string]$Module = 'Microsoft.UI.Xaml.Controls.dll', + [string]$Flavor = 'amd64chk', + [string]$BaselinePerFileCsv = '', + [string]$BaselineLabel = 'Baseline', + [PSCredential]$Credential, + [switch]$MoveSessionToConsole, + [switch]$RefreshPayload, + [switch]$ForceReinstrument, + [switch]$SkipFunctionCoverage +) +$ErrorActionPreference = 'Stop' +$scripts = $PSScriptRoot +New-Item -ItemType Directory -Force $OutputDir | Out-Null +$OutputDir = (Resolve-Path $OutputDir).Path +$work = Join-Path $OutputDir 'work' +$plat = if ($Flavor -match '^(amd64|x86|arm64)') { @{ amd64 = 'x64'; x86 = 'x86'; arm64 = 'arm64' }[$Matches[1]] } else { 'x64' } +$cfg = if ($Flavor -match 'fre$') { 'fre' } else { 'chk' } +$payloadFlavor = "$plat$cfg" +$runLabel = Get-Date -Format 'yyyyMMdd-HHmmss' +$transcript = Join-Path $OutputDir "coverage-run-$runLabel.log" +Start-Transcript $transcript | Out-Null +try { + $sw = [Diagnostics.Stopwatch]::StartNew() + if ($RefreshPayload) { + Write-Host "== Refreshing TestPayload\$payloadFlavor from BuildOutput" + Push-Location $RepoRoot + try { & .\initrun.ps1 -Flavor $Flavor .\test\CreateTestPayload.ps1 -Platform $plat -Configuration $cfg -Mode DevTestSuite -SkipSymbols -Quiet } + finally { Pop-Location } + } + + Write-Host "== Step 1: instrumentable $Module" + if ($Module -eq 'Microsoft.UI.Xaml.Controls.dll') { + $rl = & (Join-Path $scripts 'Step1-New-InstrumentableDll.ps1') -RepoRoot $RepoRoot -OutputDir (Join-Path $work 'relink') -Flavor $Flavor + $srcDll = $rl.dll + } else { + # MUX already links with /debugtype:cv,fixup,pdata; stage its DLL+PDB side by side. + $d = Join-Path $work 'mux'; New-Item -ItemType Directory -Force $d | Out-Null + Copy-Item (Join-Path $RepoRoot "BuildOutput\bin\$Flavor\Product\Microsoft.ui.xaml.dll") $d -Force + Copy-Item (Get-ChildItem (Join-Path $RepoRoot "BuildOutput\bin\$Flavor") -Recurse -Filter Microsoft.ui.xaml.pdb | Select-Object -First 1).FullName $d -Force + $srcDll = Join-Path $d 'Microsoft.ui.xaml.dll' + } + + Write-Host "== Step 2: instrument + broadcast into payload" + $st = & (Join-Path $scripts 'Step2-Instrument-Payload.ps1') -RepoRoot $RepoRoot -WorkDir $work -SourceDll $srcDll -PayloadFlavor $payloadFlavor -ForceReinstrument:$ForceReinstrument + + Write-Host "== Step 3: collector + tests on $VMName" + $runs = @() + for ($i = 0; $i -lt $TestFilter.Count; $i++) { + $label = if ($TestFilter.Count -gt 1) { "$runLabel-$($i + 1)" } else { $runLabel } + Write-Host " filter $($i + 1)/$($TestFilter.Count): $($TestFilter[$i])" + $runs += & (Join-Path $scripts 'Step3-Invoke-VmCoverageRun.ps1') -RepoRoot $RepoRoot -WorkDir $work -VMName $VMName -TestFilter $TestFilter[$i] ` + -Flavor $Flavor -Credential $Credential -RunLabel $label -MoveSessionToConsole:$MoveSessionToConsole + } +} +finally { + Write-Host "== Step 4: restore payload + VM" + try { & (Join-Path $scripts 'Step4-Restore-Payload.ps1') -RepoRoot $RepoRoot -WorkDir $work -VMName $VMName -Credential $Credential } + catch { Write-Warning "RESTORE FAILED - fix manually: $_" } +} +try { + Write-Host "== Step 5: $ComponentName report" + $rep = & (Join-Path $scripts 'Step5-Get-ComponentCoverage.ps1') -CoverageFiles @($runs.coverageFile) -OutputDir (Join-Path $OutputDir "report-$runLabel") ` + -Module ([IO.Path]::GetFileNameWithoutExtension($Module)) -ComponentPathRegex $ComponentPathRegex -ComponentName $ComponentName ` + -BaselinePerFileCsv $BaselinePerFileCsv -BaselineLabel $BaselineLabel -RunLabel "tests '$($TestFilter -join "', '")'" -SkipFunctionCoverage:$SkipFunctionCoverage + $testSummary = ($runs | ForEach-Object testSummary) -join ' | ' + Write-Host ("== DONE in {0:hh\:mm\:ss}: {1} {2}/{3} lines = {4} % | tests: {5}" -f $sw.Elapsed, $ComponentName, $rep.coveredLines, $rep.totalLines, $rep.lineCoveragePct, $testSummary) + Write-Host " summary: $($rep.summary)" + $rep | Add-Member testSummary $testSummary -PassThru | Add-Member coverageFile @($runs.coverageFile) -PassThru +} +finally { Stop-Transcript | Out-Null } diff --git a/.github/skills/vm-component-coverage/scripts/Set-VmCredential.ps1 b/.github/skills/vm-component-coverage/scripts/Set-VmCredential.ps1 new file mode 100644 index 000000000..93376d206 --- /dev/null +++ b/.github/skills/vm-component-coverage/scripts/Set-VmCredential.ps1 @@ -0,0 +1,75 @@ +<# +.SYNOPSIS + Gate for the coverage skill: validate the VM and cache its credential WITHOUT the password ever + passing through the agent/chat. + +.DESCRIPTION + * Fails unless the Hyper-V VM exists and is Running. + * Prompts for the password in a SEPARATE console window (username pre-filled), the same way + tools\run-tests-on-vm.ps1 does, and writes the DPAPI-encrypted cache + %USERPROFILE%\.winui-test\vmcred-.xml - the cache that run-tests-on-vm.ps1 and the + Step3/Step4 scripts already read. (Step3 does not forward -Credential to run-tests-on-vm.ps1, + so the cache must exist; passing -Credential alone is not enough.) + * Validates the login over PowerShell Direct and that the user has an Active desktop session. + * Only replaces an existing cache after the new credential has been validated. +#> +[CmdletBinding()] +param( + [Parameter(Mandatory)][string]$VMName, + # Plain account name exactly as qwinsta shows it on the VM (no ".\" or "VM\" prefix): + # Step3 matches the session row with the credential's UserName. + [Parameter(Mandatory)][string]$UserName, + # Re-prompt even if a cached credential for this VM/user exists. + [switch]$Force +) +$ErrorActionPreference = 'Stop' + +if ($UserName -match '\\') { throw "Use the plain account name without a domain/machine prefix (got '$UserName')." } +$vm = Get-VM -Name $VMName -ErrorAction SilentlyContinue +if (-not $vm) { throw "Hyper-V VM '$VMName' not found (or this account is not in Hyper-V Administrators)." } +if ($vm.State -ne 'Running') { throw "VM '$VMName' is $($vm.State). Start it and log on to its desktop first." } + +$credDir = Join-Path $env:USERPROFILE '.winui-test' +New-Item -ItemType Directory -Force $credDir | Out-Null +$credFile = Join-Path $credDir "vmcred-$($VMName -replace '[^a-zA-Z0-9]','_').xml" +$newFile = "$credFile.new" +Remove-Item $newFile -ErrorAction SilentlyContinue + +$cached = if (Test-Path $credFile) { Import-Clixml $credFile } else { $null } +if ($Force -or -not $cached -or $cached.UserName -ne $UserName) { + $helper = Join-Path $credDir 'prompt-coverage-cred.ps1' + @' +param([string]$VMName, [string]$UserName, [string]$CredFile) +Write-Host "Password for '$UserName' on VM '$VMName'" -ForegroundColor Cyan +$p = Read-Host 'Password' -AsSecureString +[pscredential]::new($UserName, $p) | Export-Clixml $CredFile +'@ | Set-Content $helper -Encoding UTF8 + Write-Host "A password prompt opened in a new window..." -ForegroundColor Cyan + Start-Process powershell -Wait -ArgumentList '-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', $helper, + '-VMName', $VMName, '-UserName', $UserName, '-CredFile', $newFile + Remove-Item $helper -ErrorAction SilentlyContinue + if (-not (Test-Path $newFile)) { throw 'No password was entered; cannot continue.' } + $cred = Import-Clixml $newFile +} else { + $cred = $cached +} + +try { + $s = New-PSSession -VMName $VMName -Credential $cred +} catch { + Remove-Item $newFile -ErrorAction SilentlyContinue + throw "Could not log on to '$VMName' as '$UserName': $($_.Exception.Message)" +} +try { + $row = Invoke-Command -Session $s -ScriptBlock { + param($u) (qwinsta 2>$null) | Where-Object { $_ -match "\s$([regex]::Escape($u))\s" } | Select-Object -First 1 + } -ArgumentList $UserName +} finally { Remove-PSSession $s } + +if (Test-Path $newFile) { Move-Item $newFile $credFile -Force } +$active = "$row" -match '\sActive' +[pscustomobject]@{ + VMName = $VMName; UserName = $UserName; CredentialCache = $credFile + LogonOk = $true; DesktopSession = "$row".Trim(); DesktopActive = $active +} +if (-not $active) { throw "'$UserName' has no Active desktop session on '$VMName'. Log on to the VM desktop, then retry." } diff --git a/.github/skills/vm-component-coverage/scripts/Step1-New-InstrumentableDll.ps1 b/.github/skills/vm-component-coverage/scripts/Step1-New-InstrumentableDll.ps1 new file mode 100644 index 000000000..f5c0f58bd --- /dev/null +++ b/.github/skills/vm-component-coverage/scripts/Step1-New-InstrumentableDll.ps1 @@ -0,0 +1,121 @@ +<# +.SYNOPSIS + Step 1 - Produce an instrumentable copy of a WinUI chk product DLL (default: + Microsoft.UI.Xaml.Controls.dll) WITHOUT touching the repo or BuildOutput. + +.DESCRIPTION + Static native instrumentation (Microsoft.CodeCoverage.Console instrument) needs the CV + FIXUP + debug streams. MUXC's Debug (chk) link does not emit them, so instrumenting the normal chk DLL + prints "Unable to obtain debug information. Link with the /PROFILE linker switch." and exits 0 + with the image untouched (see microsoft-ui-xaml-lift PR 16435603, commit 2919de62: + "Make MUXC instrumentable for code coverage by emitting CV/FIXUP debug info", which adds + /debugtype:cv,fixup + /INCREMENTAL:NO behind an opt-in EnableCodeCoverageLinkOptions property). + + That property does not exist in every checkout, and this skill must not edit repo files, so this + script re-runs the EXACT link command MSBuild last recorded (link.command.1.tlog) with: + * /DEBUGTYPE:CV,FIXUP added (and /INCREMENTAL:NO enforced), + * /OUT, /PDB, /IMPLIB, /MAP redirected into -OutputDir, + * /WINMD disabled (the .winmd is not needed for coverage). + The repo's BuildOutput DLL/PDB are never overwritten. Object files are reused as-is, so the code + is byte-for-byte the same build - only the debug info differs. + + Microsoft.ui.xaml.dll (MUX) already links with /debugtype:cv,fixup,pdata, so for MUX you can skip + this step and instrument BuildOutput\bin\\Product\Microsoft.ui.xaml.dll directly. + +.EXAMPLE + .\Step1-New-InstrumentableDll.ps1 -RepoRoot C:\Repos\WinUiGitHubRepo\microsoft-ui-xaml -OutputDir C:\cov\work\relink +#> +[CmdletBinding()] +param( + [Parameter(Mandatory)][string]$RepoRoot, + [Parameter(Mandatory)][string]$OutputDir, + [string]$Flavor = 'amd64chk', + # Project-relative obj dir that holds the link .tlog for the module. + [string]$ObjSubDir = 'controls\dev\dll', + # Directory the original link ran in (relative paths such as /DEF are resolved from here). + [string]$ProjectSubDir = 'controls\dev\dll', + [string]$ModuleName = 'Microsoft.UI.Xaml.Controls.dll' +) +$ErrorActionPreference = 'Stop' +function Info($m) { Write-Host "[relink] $m" } + +$objDir = Join-Path $RepoRoot "BuildOutput\obj\$Flavor\$ObjSubDir" +$projDir = Join-Path $RepoRoot $ProjectSubDir +if (-not (Test-Path $objDir)) { throw "obj dir not found: $objDir (build the repo first)" } + +$stem = [IO.Path]::GetFileNameWithoutExtension($ModuleName) +$tlog = Get-ChildItem $objDir -Recurse -Filter 'link.command.1.tlog' | + Where-Object { ([IO.File]::ReadAllText($_.FullName)) -match [regex]::Escape("\$stem.dll".ToUpperInvariant()) } | + Select-Object -First 1 +if (-not $tlog) { throw "No link.command.1.tlog under $objDir mentions $ModuleName" } +Info "link tlog : $($tlog.FullName)" + +# tlog layout: line 0 = '^' (input list), remaining lines = the command line (options + objs). +$lines = [IO.File]::ReadAllText($tlog.FullName) -split "`r?`n" +$cmd = ($lines | Select-Object -Skip 1 | Where-Object { $_ -and -not $_.StartsWith('^') }) -join "`n" +if (-not $cmd) { throw "Could not read link command from $($tlog.FullName)" } + +New-Item -ItemType Directory -Force $OutputDir | Out-Null +$OutputDir = (Resolve-Path $OutputDir).Path +$outDll = Join-Path $OutputDir $ModuleName +$outPdb = Join-Path $OutputDir "$stem.pdb" +$outLib = Join-Path $OutputDir "$stem.lib" +$outMap = Join-Path $OutputDir "$stem.map" + +# The link is not byte-deterministic, and Step 2 caches the (slow) instrumentation by the relinked +# DLL's hash, so reuse the previous relink when the recorded link command/inputs are unchanged. +$resultFile = Join-Path $OutputDir 'relink-result.json' +$inputsStamp = '{0}|{1}' -f (Get-FileHash $tlog.FullName).Hash, + ((Get-ChildItem (Split-Path $tlog.FullName) -Filter 'link.read.*.tlog' | Get-FileHash | ForEach-Object Hash) -join ',') +$newestObj = Get-ChildItem $objDir -Filter *.obj | Sort-Object LastWriteTimeUtc -Descending | Select-Object -First 1 +if ((Test-Path $resultFile) -and (Test-Path $outDll) -and (Test-Path $outPdb)) { + $prev = Get-Content $resultFile -Raw | ConvertFrom-Json + if ($prev.inputsStamp -eq $inputsStamp -and (-not $newestObj -or $newestObj.LastWriteTimeUtc -lt (Get-Item $outDll).LastWriteTimeUtc)) { + Info "reusing previous relink (link inputs unchanged): $outDll" + return [pscustomobject]@{ dll = $outDll; pdb = $outPdb } + } +} + +$q = '(?:"[^"]*"|\S+)' +$cmd = [regex]::Replace($cmd, "(?i)/OUT:$q", "/OUT:`"$outDll`"") +$cmd = [regex]::Replace($cmd, "(?i)/PDB:$q", "/PDB:`"$outPdb`"") +$cmd = [regex]::Replace($cmd, "(?i)/IMPLIB:$q", "/IMPLIB:`"$outLib`"") +$cmd = [regex]::Replace($cmd, "(?i)/WINMDFILE:$q", '') +$cmd = [regex]::Replace($cmd, "(?i)/WINMD(?=\s)", '/WINMD:NO') +$cmd = [regex]::Replace($cmd, "(?i)/MAP(?=\s)", "/MAP:`"$outMap`"") +$cmd = [regex]::Replace($cmd, "(?i)/INCREMENTAL(:\w+)?", '/INCREMENTAL:NO') +$cmd = [regex]::Replace($cmd, "(?i)/DEBUGTYPE:$q", '') +$cmd += "`n/DEBUGTYPE:CV,FIXUP" + +$rsp = Join-Path $OutputDir 'relink.rsp' +[IO.File]::WriteAllText($rsp, $cmd, [Text.Encoding]::Unicode) +Info "response : $rsp" + +# Resolve link.exe from the MSVC toolset recorded in the command's /LIBPATH (same toolset that built the objs). +$link = $null +$m = [regex]::Match($cmd, '(?i)"([^"]*\\VC\\TOOLS\\MSVC\\[\d\.]+)\\LIB\\X64"') +if ($m.Success) { + $cand = Join-Path $m.Groups[1].Value 'bin\Hostx64\x64\link.exe' + if (Test-Path $cand) { $link = $cand } +} +if (-not $link) { + $vswhere = "${env:ProgramFiles(x86)}\Microsoft Visual Studio\Installer\vswhere.exe" + $vs = & $vswhere -latest -products * -property installationPath + $link = Get-ChildItem "$vs\VC\Tools\MSVC" -Recurse -Filter link.exe | Where-Object FullName -match 'Hostx64\\x64' | Select-Object -First 1 -ExpandProperty FullName +} +if (-not $link) { throw 'link.exe not found' } +Info "linker : $link" + +$sw = [Diagnostics.Stopwatch]::StartNew() +Push-Location $projDir +try { + $out = & $link "@$rsp" 2>&1 + $exit = $LASTEXITCODE +} finally { Pop-Location } +$out | Where-Object { $_ -match 'error|warning LNK' } | Select-Object -First 20 | ForEach-Object { Info " $_" } +if ($exit -ne 0 -or -not (Test-Path $outDll)) { throw "relink failed (exit $exit)" } +Info ("relinked in {0:mm\:ss}: {1} ({2:N0} bytes)" -f $sw.Elapsed, $outDll, (Get-Item $outDll).Length) + +[pscustomobject]@{ dll = $outDll; pdb = $outPdb; linkTlog = $tlog.FullName; linker = $link; rsp = $rsp; inputsStamp = $inputsStamp } | + ConvertTo-Json | Set-Content $resultFile -Encoding UTF8 +[pscustomobject]@{ dll = $outDll; pdb = $outPdb } diff --git a/.github/skills/vm-component-coverage/scripts/Step2-Instrument-Payload.ps1 b/.github/skills/vm-component-coverage/scripts/Step2-Instrument-Payload.ps1 new file mode 100644 index 000000000..8ee47d598 --- /dev/null +++ b/.github/skills/vm-component-coverage/scripts/Step2-Instrument-Payload.ps1 @@ -0,0 +1,142 @@ +<# +.SYNOPSIS + Step 2 - Statically instrument the module and broadcast it into the local test payload. + +.DESCRIPTION + Mirrors Helix\common\pipeline\coverage\Instrument-CoveragePayload.ps1 from microsoft-ui-xaml-lift + PR 16435603, adapted for a local payload that is deployed to a Hyper-V VM: + + * Instruments ONE copy (VS Microsoft.CodeCoverage.Console.exe, static native instrumentation, + a fresh 16-hex session id) - this is slow for a 38 MB chk DLL (~10-20 min), so the result is + CACHED in \instrumented keyed by the SHA256 of the input DLL and reused next time + (together with the session id that is baked into it). + * Detects "fails while exiting 0" (no /PROFILE-style debug info) by checking the tool output + and that the image grew, and requires static_covrun*.dll to be produced. + * Backs up every payload copy of the module to \payload-backup (once), then copies + the instrumented DLL + static_covrun*.dll over every copy (a test app loads its own + co-located copy, e.g. Test\UnpackagedApps\MUXControlsTestApp\). + * Writes \instrument-state.json (session id, copies, backups, runtimes added) used by + the run and restore steps. + + Never edits repo source. Only TestPayload\ (a generated folder) is modified, and + Step4-Restore-Payload.ps1 restores it byte-for-byte. +#> +[CmdletBinding()] +param( + [Parameter(Mandatory)][string]$RepoRoot, + [Parameter(Mandatory)][string]$WorkDir, + # The instrumentable DLL (Step 1 output) and its PDB next to it. + [Parameter(Mandatory)][string]$SourceDll, + [string]$PayloadFlavor = 'x64chk', + [string]$CoverageConsole = '', + [switch]$ForceReinstrument +) +$ErrorActionPreference = 'Stop' +function Info($m) { Write-Host "[instrument] $m" } + +if (-not $CoverageConsole) { + # Highest file version wins (string-sorting install paths would rank "2022" above "18"). + $CoverageConsole = Get-ChildItem "${env:ProgramFiles}\Microsoft Visual Studio" -Recurse -Filter Microsoft.CodeCoverage.Console.exe -Depth 8 -EA SilentlyContinue | + Sort-Object { [version]$_.VersionInfo.FileVersion } -Descending | Select-Object -First 1 -ExpandProperty FullName +} +if (-not $CoverageConsole -or -not (Test-Path $CoverageConsole)) { throw 'Microsoft.CodeCoverage.Console.exe not found (install VS Enterprise code coverage).' } +$toolVersion = (Get-Item $CoverageConsole).VersionInfo.FileVersion +Info "tool : $CoverageConsole ($toolVersion)" + +$moduleName = Split-Path $SourceDll -Leaf +$stem = [IO.Path]::GetFileNameWithoutExtension($moduleName) +$pdb = Join-Path (Split-Path $SourceDll) "$stem.pdb" +if (-not (Test-Path $pdb)) { throw "PDB must sit next to the DLL: $pdb" } + +New-Item -ItemType Directory -Force $WorkDir | Out-Null +$WorkDir = (Resolve-Path $WorkDir).Path +$instrDir = Join-Path $WorkDir 'instrumented' +$cacheFile = Join-Path $instrDir 'instrumented-cache.json' +$srcHash = (Get-FileHash $SourceDll).Hash + +# ---- 1. instrument (or reuse cache) --------------------------------------------------------- +$cache = if (Test-Path $cacheFile) { Get-Content $cacheFile -Raw | ConvertFrom-Json } else { $null } +$instrDll = Join-Path $instrDir $moduleName +if (-not $ForceReinstrument -and $cache -and $cache.sourceHash -eq $srcHash -and (Test-Path $instrDll) -and + (Get-FileHash $instrDll).Hash -eq $cache.instrumentedHash) { + $sessionId = $cache.sessionId + # The collector must come from the same tool that instrumented the binary. + if ($cache.coverageTool -and (Test-Path $cache.coverageTool)) { $CoverageConsole = $cache.coverageTool; $toolVersion = $cache.toolVersion } + Info "reusing cached instrumented DLL (session $sessionId, tool $toolVersion)" +} else { + Remove-Item $instrDir -Recurse -Force -EA SilentlyContinue + New-Item -ItemType Directory -Force $instrDir | Out-Null + Copy-Item $SourceDll, $pdb $instrDir + $sessionId = [guid]::NewGuid().ToString('N').Substring(0, 16) + $cfg = Join-Path $instrDir 'instrument.config' + @' + + + + True + False + .* + + +'@ | Set-Content $cfg -Encoding UTF8 + $before = (Get-Item $instrDll).Length + Info "instrumenting $moduleName with session $sessionId (slow for large chk DLLs)..." + $sw = [Diagnostics.Stopwatch]::StartNew() + $out = & $CoverageConsole instrument $instrDll --session-id $sessionId --settings $cfg --nologo 2>&1 + $exit = $LASTEXITCODE + $text = ($out | ForEach-Object { "$_" }) -join "`n" + $text | Set-Content (Join-Path $instrDir 'instrument.log') + $after = (Get-Item $instrDll).Length + Info ("instrument exit={0} in {1:mm\:ss}; size {2:N0} -> {3:N0}" -f $exit, $sw.Elapsed, $before, $after) + if ($exit -ne 0 -or $text -match 'Unable to obtain debug information|/PROFILE linker switch' -or + -not ($text -match 'successfully instrumented' -or $after -gt $before)) { + throw "Instrumentation failed or silently skipped (see $instrDir\instrument.log). If it mentions /PROFILE, run Step1 (relink with /DEBUGTYPE:CV,FIXUP) first." + } + if (-not (Get-ChildItem $instrDir -Filter 'static_covrun*.dll')) { throw "static_covrun*.dll was not produced next to $instrDll" } + [pscustomobject]@{ sessionId = $sessionId; sourceDll = $SourceDll; sourceHash = $srcHash + instrumentedHash = (Get-FileHash $instrDll).Hash; coverageTool = $CoverageConsole; toolVersion = $toolVersion + createdAt = (Get-Date).ToString('o') } | + ConvertTo-Json | Set-Content $cacheFile -Encoding UTF8 +} +$runtimes = @(Get-ChildItem $instrDir -Filter 'static_covrun*.dll') + +# ---- 2. broadcast into the payload (backup originals once) ---------------------------------- +$payload = Join-Path $RepoRoot "TestPayload\$PayloadFlavor" +if (-not (Test-Path (Join-Path $payload 'runtests.cmd'))) { throw "Test payload not found at $payload. Run tools\run-tests-on-vm.ps1 (or test\CreateTestPayload.ps1) once first." } +$backupRoot = Join-Path $WorkDir 'payload-backup' +$statePath = Join-Path $WorkDir 'instrument-state.json' +$state = if (Test-Path $statePath) { Get-Content $statePath -Raw | ConvertFrom-Json } else { $null } +if ($state -and $state.payloadDir -ne $payload) { throw "instrument-state.json belongs to another payload ($($state.payloadDir)); restore it first." } + +$copies = @(Get-ChildItem $payload -Recurse -Filter $moduleName -File) +if (-not $copies) { throw "No copies of $moduleName under $payload" } +$instrHash = (Get-FileHash $instrDll).Hash +$entries = @() +$runtimesAdded = @() +foreach ($c in $copies) { + $rel = $c.FullName.Substring($payload.Length + 1) + $bak = Join-Path $backupRoot $rel + if (-not (Test-Path $bak)) { + if ((Get-FileHash $c.FullName).Hash -eq $instrHash) { throw "$rel is already instrumented but has no backup; recreate the payload." } + New-Item -ItemType Directory -Force (Split-Path $bak) | Out-Null + Copy-Item $c.FullName $bak + } + Copy-Item $instrDll $c.FullName -Force + foreach ($rt in $runtimes) { + $rtDest = Join-Path $c.DirectoryName $rt.Name + if (-not (Test-Path $rtDest)) { $runtimesAdded += $rtDest.Substring($payload.Length + 1) } + Copy-Item $rt.FullName $rtDest -Force + } + $entries += [pscustomobject]@{ relativePath = $rel; backup = $bak; originalHash = (Get-FileHash $bak).Hash } + Info " instrumented copy -> $rel" +} +if ($state -and $state.runtimesAdded) { $runtimesAdded = @($state.runtimesAdded) + $runtimesAdded | Select-Object -Unique } + +$newState = [pscustomobject]@{ + module = $moduleName; moduleStem = $stem; sessionId = $sessionId; payloadDir = $payload + instrumentedDll = $instrDll; instrumentedHash = $instrHash; coverageTool = $CoverageConsole; toolVersion = $toolVersion + copies = $entries; runtimesAdded = @($runtimesAdded); instrumentedAt = (Get-Date).ToString('o') +} +$newState | ConvertTo-Json -Depth 5 | Set-Content $statePath -Encoding UTF8 +Info "payload instrumented: $($entries.Count) copies, session $sessionId (state: $statePath)" +$newState diff --git a/.github/skills/vm-component-coverage/scripts/Step3-Invoke-VmCoverageRun.ps1 b/.github/skills/vm-component-coverage/scripts/Step3-Invoke-VmCoverageRun.ps1 new file mode 100644 index 000000000..a9e3114fb --- /dev/null +++ b/.github/skills/vm-component-coverage/scripts/Step3-Invoke-VmCoverageRun.ps1 @@ -0,0 +1,193 @@ +<# +.SYNOPSIS + Step 3 - Run the selected tests on a Hyper-V VM with a code-coverage collector attached and + download the resulting .coverage file. + +.DESCRIPTION + Collector mechanics follow microsoft-ui-xaml-lift PR 16435603 + (Helix\common\pipeline\RunTestPassSliceOnBuildAgent.ps1 -CollectCodeCoverage): + 1. Stage the VS code-coverage tools (CodeCoverage.Console + Dynamic Code Coverage Tools + + InstrumentationEngine) on the VM once (C:\WinUICoverage\CoverageTool), no VS/dotnet needed. + 2. Start "Microsoft.CodeCoverage.Console.exe collect --session-id --server-mode --output " + as the logged-in VM user (interactive scheduled task - it must outlive the PS Direct session). + 3. Wait for \\.\pipe\CodeCoverage.pipe., then give it a NULL DACL + (lib\Set-CoveragePipeAcl.ps1) so low-integrity test processes can connect. + 4. Run the tests with the repo's own tools\run-tests-on-vm.ps1 -SkipPayload (deploys the + instrumented payload incrementally and runs runtests.cmd on the interactive desktop). + 5. "Microsoft.CodeCoverage.Console.exe shutdown " -> collector finalizes the .coverage. + 6. Copy the .coverage + collector logs back to \raw. + + Precondition: the VM user's desktop must be interactive. If the host is a Cloud PC / RDP session + that can disconnect, use -MoveSessionToConsole so input does not depend on vmconnect's enhanced + session (runs "tscon /dest:console" as SYSTEM inside the VM). +#> +[CmdletBinding()] +param( + [Parameter(Mandatory)][string]$RepoRoot, + [Parameter(Mandatory)][string]$WorkDir, + [Parameter(Mandatory)][string]$VMName, + [Parameter(Mandatory)][string]$TestFilter, + [string]$Flavor = 'amd64chk', + [PSCredential]$Credential, + [string]$RunLabel = '', + [switch]$MoveSessionToConsole, + [int]$PipeTimeoutSec = 90, + [int]$FlushTimeoutSec = 600 +) +$ErrorActionPreference = 'Stop' +function Info($m) { Write-Host "[vm-run] $m" } + +$WorkDir = (Resolve-Path $WorkDir).Path +$state = Get-Content (Join-Path $WorkDir 'instrument-state.json') -Raw | ConvertFrom-Json +$sid = $state.sessionId +if (-not $RunLabel) { $RunLabel = Get-Date -Format 'yyyyMMdd-HHmmss' } +$rawDir = Join-Path $WorkDir "raw\$RunLabel" +New-Item -ItemType Directory -Force $rawDir | Out-Null + +# Credential: same per-user DPAPI cache tools\run-tests-on-vm.ps1 uses. +if (-not $Credential) { + $credFile = Join-Path $env:USERPROFILE ".winui-test\vmcred-$($VMName -replace '[^a-zA-Z0-9]','_').xml" + if (-not (Test-Path $credFile)) { throw "No cached VM credential ($credFile). Run tools\run-tests-on-vm.ps1 once, or pass -Credential." } + $Credential = Import-Clixml $credFile +} +$user = $Credential.UserName +$session = New-PSSession -VMName $VMName -Credential $Credential + +$vmRoot = 'C:\WinUICoverage' +# Collector must be the same tool version that instrumented the binary (recorded by Step 2). +$hostTool = $state.coverageTool +if (-not $hostTool -or -not (Test-Path $hostTool)) { + $hostTool = Get-ChildItem "${env:ProgramFiles}\Microsoft Visual Studio" -Recurse -Filter Microsoft.CodeCoverage.Console.exe -Depth 8 -EA SilentlyContinue | + Sort-Object { [version]$_.VersionInfo.FileVersion } -Descending | Select-Object -First 1 -ExpandProperty FullName +} +$toolVer = (Get-Item $hostTool).VersionInfo.FileVersion +$vmToolDir = "$vmRoot\CoverageTool\$toolVer" +$vmTool = "$vmToolDir\Microsoft.CodeCoverage.Console.exe" +$vmOut = "$vmRoot\out\coverage-$RunLabel-$($state.moduleStem).coverage" +$vmLog = "$vmRoot\out\collector-$RunLabel.log" + +try { + # ---- interactive desktop check ----------------------------------------------------------- + $desk = Invoke-Command -Session $session -ScriptBlock { + param($u) + $rows = (qwinsta 2>$null) | Select-Object -Skip 1 + $mine = $rows | Where-Object { $_ -match "\s$u\s" } | Select-Object -First 1 + [pscustomobject]@{ line = "$mine"; onConsole = ("$mine" -match '^\s*>?console\s'); active = ("$mine" -match '\sActive') ; + id = if ("$mine" -match "\s$u\s+(\d+)\s") { [int]$Matches[1] } else { -1 } } + } -ArgumentList $user + Info "VM session: $($desk.line.Trim())" + if (-not $desk.active) { throw "User $user has no Active session on $VMName. Log on to the VM desktop first." } + if (-not $desk.onConsole) { + if ($MoveSessionToConsole) { + Info "moving session $($desk.id) to the VM console (tscon as SYSTEM)" + Invoke-Command -Session $session -ScriptBlock { + param($id) + $a = New-ScheduledTaskAction -Execute "$env:windir\System32\tscon.exe" -Argument "$id /dest:console" + $p = New-ScheduledTaskPrincipal -UserId 'SYSTEM' -LogonType ServiceAccount -RunLevel Highest + Register-ScheduledTask -TaskName 'WinUICov-tscon' -Action $a -Principal $p -Force | Out-Null + Start-ScheduledTask 'WinUICov-tscon'; Start-Sleep 5 + Unregister-ScheduledTask 'WinUICov-tscon' -Confirm:$false + } -ArgumentList $desk.id + } else { + Write-Warning "User session is not on the VM console. If the host's vmconnect/RDP session disconnects, UI input fails ('Access is denied'). Re-run with -MoveSessionToConsole to avoid that." + } + } + + # ---- 1. stage collector tools (once) ------------------------------------------------------ + $hasTool = Invoke-Command -Session $session -ScriptBlock { param($t) Test-Path $t } -ArgumentList $vmTool + if (-not $hasTool) { + $cc = $hostTool + $vsBase = $cc.Substring(0, $cc.IndexOf('\Common7\')) + $stage = Join-Path $WorkDir "CoverageTool\$toolVer" + Remove-Item $stage -Recurse -Force -EA SilentlyContinue + New-Item -ItemType Directory -Force $stage, "$stage\amd64", "$stage\x64", "$stage\x86" | Out-Null + Copy-Item "$vsBase\Common7\IDE\Extensions\Microsoft\CodeCoverage.Console\*" $stage -Recurse -Force + $van = "$vsBase\Team Tools\Dynamic Code Coverage Tools" + if (Test-Path $van) { Copy-Item "$van\*" $stage -Recurse -Force; if (Test-Path "$van\amd64") { Copy-Item "$van\amd64\*" "$stage\amd64" -Recurse -Force } } + $ie = "$vsBase\Common7\IDE\CommonExtensions\Platform\InstrumentationEngine" + foreach ($a in 'x64', 'x86') { if (Test-Path "$ie\$a") { Copy-Item "$ie\$a\*" "$stage\$a" -Recurse -Force } } + $zip = Join-Path $WorkDir "CoverageTool-$toolVer.zip" + Remove-Item $zip -EA SilentlyContinue + Compress-Archive -Path "$stage\*" -DestinationPath $zip + Invoke-Command -Session $session -ScriptBlock { param($r, $d) New-Item -ItemType Directory -Force $d, "$r\out" | Out-Null } -ArgumentList $vmRoot, $vmToolDir + Copy-Item $zip "$vmRoot\CoverageTool.zip" -ToSession $session -Force + Invoke-Command -Session $session -ScriptBlock { param($r, $d) Expand-Archive "$r\CoverageTool.zip" $d -Force; Remove-Item "$r\CoverageTool.zip" } -ArgumentList $vmRoot, $vmToolDir + Info "staged collector tools ($toolVer) on VM: $vmTool" + } + Invoke-Command -Session $session -ScriptBlock { param($r) New-Item -ItemType Directory -Force "$r\out" | Out-Null } -ArgumentList $vmRoot + Copy-Item (Join-Path $PSScriptRoot 'lib\Set-CoveragePipeAcl.ps1') "$vmRoot\Set-CoveragePipeAcl.ps1" -ToSession $session -Force + + # ---- 2. start the collector as the interactive user --------------------------------------- + Invoke-Command -Session $session -ScriptBlock { + param($tool, $sid, $out, $log, $u, $r) + if (Test-Path "\\.\pipe\CodeCoverage.pipe.$sid") { throw "A collector for session $sid is already running on the VM. Run with a fresh session or shut it down: `"$tool`" shutdown $sid" } + Remove-Item $out, $log -EA SilentlyContinue + # Use a .cmd wrapper: "cmd /c" strips the outer quotes of a command line that starts and + # ends with a quote, which silently breaks a quoted exe path + quoted redirections. + $wrapper = "$r\collector-wrapper.cmd" + "@echo off`r`n`"$tool`" collect --session-id $sid --server-mode --output `"$out`" > `"$log`" 2>&1" | Set-Content $wrapper -Encoding ASCII + $a = New-ScheduledTaskAction -Execute 'cmd.exe' -Argument "/c `"$wrapper`"" + $p = New-ScheduledTaskPrincipal -UserId $u -LogonType Interactive -RunLevel Highest + $s = New-ScheduledTaskSettingsSet -ExecutionTimeLimit ([TimeSpan]::Zero) + Register-ScheduledTask -TaskName 'WinUICov-Collector' -Action $a -Principal $p -Settings $s -Force | Out-Null + Start-ScheduledTask 'WinUICov-Collector' + } -ArgumentList $vmTool, $sid, $vmOut, $vmLog, $user, $vmRoot + Info "collector started (session $sid) -> $vmOut" + + # ---- 3. wait for the pipe, open its DACL --------------------------------------------------- + $ready = Invoke-Command -Session $session -ScriptBlock { + param($sid, $timeout, $log) + $deadline = (Get-Date).AddSeconds($timeout) + while ((Get-Date) -lt $deadline) { + if (Test-Path "\\.\pipe\CodeCoverage.pipe.$sid") { return $true } + if ((Get-ScheduledTask 'WinUICov-Collector').State -ne 'Running') { break } + Start-Sleep -Milliseconds 500 + } + Get-Content $log -EA SilentlyContinue | Select-Object -Last 20 | ForEach-Object { Write-Host " collector: $_" } + return $false + } -ArgumentList $sid, $PipeTimeoutSec, $vmLog + if (-not $ready) { throw "Collector pipe for session $sid did not appear on the VM." } + Invoke-Command -Session $session -ScriptBlock { + param($r, $sid) & powershell.exe -NoProfile -ExecutionPolicy Bypass -File "$r\Set-CoveragePipeAcl.ps1" -SessionId $sid + } -ArgumentList $vmRoot, $sid | ForEach-Object { Info " $_" } + + # ---- 4. run the tests with the repo's VM runner (payload already instrumented) ------------- + $testLog = Join-Path $rawDir 'vm-test.log' + Info "running tests: run-tests-on-vm.ps1 -SkipPayload $TestFilter (log: $testLog)" + Push-Location $RepoRoot + try { + & (Join-Path $RepoRoot 'initrun.ps1') -Flavor $Flavor .\tools\run-tests-on-vm.ps1 -VMName $VMName -SkipPayload $TestFilter *>&1 | + Tee-Object -FilePath $testLog | Where-Object { "$_" -match 'Summary:|EndGroup:|Tests (PASSED|FAILED)|Error|\[\d/3\]' } | ForEach-Object { Write-Host " $_" } + } finally { Pop-Location } + $summary = Select-String $testLog -Pattern 'Summary:\s+Total=' | Select-Object -Last 1 + Info "test summary: $($summary.Line.Trim())" +} +finally { + # ---- 5. always flush: shut the collector down, wait for the file -------------------------- + try { + $flush = Invoke-Command -Session $session -ScriptBlock { + param($tool, $sid, $out, $timeout) + $o = if (Test-Path "\\.\pipe\CodeCoverage.pipe.$sid") { & $tool shutdown $sid 2>&1 | Out-String } else { 'no collector pipe (collector not running)' } + $deadline = (Get-Date).AddSeconds($timeout) + do { Start-Sleep 3; $t = Get-ScheduledTask 'WinUICov-Collector' -EA SilentlyContinue } while ($t -and $t.State -eq 'Running' -and (Get-Date) -lt $deadline) + if ($t -and $t.State -eq 'Running') { Stop-ScheduledTask 'WinUICov-Collector' } + Unregister-ScheduledTask 'WinUICov-Collector' -Confirm:$false -EA SilentlyContinue + [pscustomobject]@{ shutdown = $o.Trim(); size = if (Test-Path $out) { (Get-Item $out).Length } else { 0 } } + } -ArgumentList $vmTool, $sid, $vmOut, $FlushTimeoutSec + Info ("collector shutdown: {0}; coverage size {1:N0} bytes" -f $flush.shutdown, $flush.size) + } catch { Write-Warning "collector shutdown failed: $_" } +} + +# ---- 6. download ------------------------------------------------------------------------------- +try { + $local = Join-Path $rawDir (Split-Path $vmOut -Leaf) + Copy-Item $vmOut $local -FromSession $session -Force + Copy-Item $vmLog (Join-Path $rawDir 'collector.log') -FromSession $session -Force -EA SilentlyContinue +} finally { Remove-PSSession $session } +if (-not (Test-Path $local) -or (Get-Item $local).Length -lt 1024) { + throw "Coverage file missing or empty ($local). Check $rawDir\collector.log and that the test process loaded the instrumented DLL (session $sid)." +} +Info ("downloaded {0} ({1:N1} MB)" -f $local, ((Get-Item $local).Length / 1MB)) +[pscustomobject]@{ coverageFile = $local; testLog = $testLog; testSummary = "$($summary.Line)".Trim(); sessionId = $sid; rawDir = $rawDir } | + Tee-Object -Variable result | ConvertTo-Json | Set-Content (Join-Path $rawDir 'run-result.json') -Encoding UTF8 +$result diff --git a/.github/skills/vm-component-coverage/scripts/Step4-Restore-Payload.ps1 b/.github/skills/vm-component-coverage/scripts/Step4-Restore-Payload.ps1 new file mode 100644 index 000000000..40c010e29 --- /dev/null +++ b/.github/skills/vm-component-coverage/scripts/Step4-Restore-Payload.ps1 @@ -0,0 +1,69 @@ +<# +.SYNOPSIS + Step 4 - Restore the original (uninstrumented) module in the local payload and on the VM. + +.DESCRIPTION + Uses \instrument-state.json written by Step 2: + * copies every backed-up original back over its payload copy (hash-verified), + * deletes the static_covrun*.dll files that Step 2 added, + * optionally (-VMName) does the same inside the VM's deployed payload + (C:\TestPayload\-, the path tools\run-tests-on-vm.ps1 uses), + * removes instrument-state.json (backups are kept for audit). + The deploy manifest of tools\run-tests-on-vm.ps1 is size+mtime based, so the next normal run + re-deploys the restored files anyway; the VM step just makes the VM clean immediately. +#> +[CmdletBinding()] +param( + [Parameter(Mandatory)][string]$RepoRoot, + [Parameter(Mandatory)][string]$WorkDir, + [string]$VMName, + [PSCredential]$Credential +) +$ErrorActionPreference = 'Stop' +function Info($m) { Write-Host "[restore] $m" } + +$statePath = Join-Path $WorkDir 'instrument-state.json' +if (-not (Test-Path $statePath)) { Info 'nothing to restore (no instrument-state.json)'; return } +$state = Get-Content $statePath -Raw | ConvertFrom-Json +$payload = $state.payloadDir + +foreach ($c in $state.copies) { + $dest = Join-Path $payload $c.relativePath + Copy-Item $c.backup $dest -Force + if ((Get-FileHash $dest).Hash -ne $c.originalHash) { throw "restore hash mismatch: $dest" } + Info "restored $($c.relativePath)" +} +foreach ($r in $state.runtimesAdded) { + $p = Join-Path $payload $r + if (Test-Path $p) { Remove-Item $p -Force; Info "removed $r" } +} + +if ($VMName) { + if (-not $Credential) { + $Credential = Import-Clixml (Join-Path $env:USERPROFILE ".winui-test\vmcred-$($VMName -replace '[^a-zA-Z0-9]','_').xml") + } + $remoteDir = "C:\TestPayload\$env:COMPUTERNAME-$(Split-Path $RepoRoot -Leaf)" + $s = New-PSSession -VMName $VMName -Credential $Credential + try { + Invoke-Command -Session $s -ScriptBlock { New-Item -ItemType Directory -Force C:\WinUICoverage | Out-Null } + # Copies can come from different originals (e.g. an app folder with an older staged DLL), + # so restore each distinct original to exactly the copies it was backed up from. + foreach ($g in ($state.copies | Group-Object originalHash)) { + $tmp = "C:\WinUICoverage\restore-$($g.Name.Substring(0,12))-$($state.module)" + Copy-Item $g.Group[0].backup $tmp -ToSession $s -Force + Invoke-Command -Session $s -ScriptBlock { + param($dir, $tmp, [string[]]$rels, $hash) + if (-not (Test-Path $dir)) { return "remote payload $dir not found; skipped" } + foreach ($r in $rels) { $d = Join-Path $dir $r; if (Test-Path (Split-Path $d)) { Copy-Item $tmp $d -Force } } + Remove-Item $tmp -Force + $bad = @($rels | Where-Object { (Test-Path (Join-Path $dir $_)) -and (Get-FileHash (Join-Path $dir $_)).Hash -ne $hash }) + if ($bad) { "WARNING: hash mismatch on VM for $($bad -join ', ')" } else { "VM restored $($rels.Count) copy(ies) of original $($hash.Substring(0,8))" } + } -ArgumentList $remoteDir, $tmp, @($g.Group.relativePath), $g.Name | ForEach-Object { Info $_ } + } + Invoke-Command -Session $s -ScriptBlock { + param($dir, [string[]]$rts) foreach ($r in $rts) { Remove-Item (Join-Path $dir $r) -Force -EA SilentlyContinue } + } -ArgumentList $remoteDir, @($state.runtimesAdded) + } finally { Remove-PSSession $s } +} +Remove-Item $statePath -Force +Info 'done' diff --git a/.github/skills/vm-component-coverage/scripts/Step5-Get-ComponentCoverage.ps1 b/.github/skills/vm-component-coverage/scripts/Step5-Get-ComponentCoverage.ps1 new file mode 100644 index 000000000..fe9b1b573 --- /dev/null +++ b/.github/skills/vm-component-coverage/scripts/Step5-Get-ComponentCoverage.ps1 @@ -0,0 +1,155 @@ +<# +.SYNOPSIS + Step 5 - Turn the downloaded .coverage file(s) into a component-scoped coverage report. + +.DESCRIPTION + Reuses the (unmodified) scripts from the SkillToGetCodeCoverageFromPipeline skill, copied into + .\lib: + * dotnet-coverage merge -f cobertura -> merged.cobertura.xml + * lib\Split-CoverageByModule.ps1 -> per-module, (file,line)-deduplicated numbers + (same correction that makes numbers match ADO) + * lib\Build-CoverageTreeReport.ps1 -> single-file HTML tree for the component + * lib\Get-FunctionCoverage.ps1 -> per-function coverage (filtered to the component) + and adds: + * component filtering (-ComponentPathRegex, matched against the source path), + * an optional before/after comparison against a baseline corrected-per-file.csv (e.g. from a + CI coverage build), matched on the repo-relative path so build-agent prefixes + (C:\__w\1\s\...) and flavors (x86chk/amd64chk) do not matter, + * coverage-summary.md + component-per-file.csv + component-functions.csv. +#> +[CmdletBinding()] +param( + [Parameter(Mandatory)][string[]]$CoverageFiles, + [Parameter(Mandatory)][string]$OutputDir, + [string]$Module = 'Microsoft.UI.Xaml.Controls', + # Regex on the source file path selecting the component's files. + [string]$ComponentPathRegex = '\\controls\\dev\\TitleBar\\|\\Generated\\TitleBar|\\generated\\Controls\\TitleBar', + [string]$ComponentName = 'TitleBar', + [string]$BaselinePerFileCsv = '', + [string]$BaselineLabel = 'Baseline', + [string]$RunLabel = 'This run', + [switch]$SkipFunctionCoverage +) +$ErrorActionPreference = 'Stop' +function Info($m) { Write-Host "[report] $m" } +$lib = Join-Path $PSScriptRoot 'lib' + +$dc = (Get-Command dotnet-coverage -EA SilentlyContinue).Source +if (-not $dc) { throw 'dotnet-coverage not found: dotnet tool install --global dotnet-coverage' } +New-Item -ItemType Directory -Force $OutputDir | Out-Null +$OutputDir = (Resolve-Path $OutputDir).Path + +# ---- merge -> cobertura ------------------------------------------------------------------------- +$cob = Join-Path $OutputDir 'merged.cobertura.xml' +Info "merging $($CoverageFiles.Count) file(s) -> $cob" +& $dc merge @CoverageFiles -f cobertura -o $cob --nologo 2>&1 | Select-Object -Last 3 | ForEach-Object { Info " $_" } +if (-not (Test-Path $cob)) { throw 'merge produced no cobertura file' } + +# ---- per-module dedup (verified to match ADO's coverage tab) ------------------------------------- +$perModule = Join-Path $OutputDir 'PerModule' +& (Join-Path $lib 'Split-CoverageByModule.ps1') -InputCobertura $cob -OutputDir $perModule -Modules $Module | Out-Null +$modDir = Get-ChildItem $perModule -Directory | Where-Object { $_.Name -ieq $Module } | Select-Object -First 1 +if (-not $modDir) { throw "Module $Module not found in coverage (modules: $((Get-ChildItem $perModule -Directory).Name -join ', '))" } +$modCsv = Join-Path $modDir.FullName 'corrected-per-file.csv' +$modSummary = Get-Content (Join-Path $modDir.FullName 'corrected-summary.json') -Raw | ConvertFrom-Json + +function Get-RepoKey([string]$p) { + # repo-relative key: strip everything up to the repo root, and normalize build flavor dirs + $k = $p.ToLowerInvariant() -replace '/', '\' + $k = $k -replace '^.*?\\(?=(controls|dxaml|buildoutput|src|test)\\)', '' + $k -replace '\\obj\\(x86|amd64|arm64)(chk|fre)\\', '\obj\\' +} + +$rows = Import-Csv $modCsv | Where-Object { $_.File -match $ComponentPathRegex } +if (-not $rows) { throw "No files matched -ComponentPathRegex '$ComponentPathRegex' in $Module coverage." } +$base = @{} +if ($BaselinePerFileCsv) { + Import-Csv $BaselinePerFileCsv | Where-Object { $_.File -match $ComponentPathRegex } | ForEach-Object { $base[(Get-RepoKey $_.File)] = $_ } +} +$comp = foreach ($r in $rows) { + $k = Get-RepoKey $r.File + $b = $base[$k] + [pscustomobject]@{ + File = $k + CoveredLines = [int]$r.CoveredLines; TotalLines = [int]$r.TotalLines + LineCoverage = if ([int]$r.TotalLines) { [math]::Round(100.0 * $r.CoveredLines / $r.TotalLines, 2) } else { 0 } + BaselineCovered = if ($b) { [int]$b.CoveredLines } else { $null } + BaselineTotal = if ($b) { [int]$b.TotalLines } else { $null } + BaselineCoverage = if ($b -and [int]$b.TotalLines) { [math]::Round(100.0 * $b.CoveredLines / $b.TotalLines, 2) } else { $null } + SourcePath = $r.File + } +} +$comp = $comp | Sort-Object File +$comp | Export-Csv (Join-Path $OutputDir 'component-per-file.csv') -NoTypeInformation +$tot = ($comp | Measure-Object TotalLines -Sum).Sum +$cov = ($comp | Measure-Object CoveredLines -Sum).Sum +$pct = if ($tot) { [math]::Round(100.0 * $cov / $tot, 2) } else { 0 } + +# HTML tree for the component (reuses the pipeline skill renderer; it needs File,CoveredLines,TotalLines) +$treeCsv = Join-Path $OutputDir 'component-tree-input.csv' +$comp | Select-Object @{n='File';e={$_.SourcePath}}, CoveredLines, TotalLines, LineCoverage | Export-Csv $treeCsv -NoTypeInformation +& (Join-Path $lib 'Build-CoverageTreeReport.ps1') -InputCsv $treeCsv -OutputDir (Join-Path $OutputDir 'TreeReport') ` + -Title "$ComponentName coverage ($RunLabel)" -ModuleNames "$Module.dll" | Out-Null + +# ---- function coverage (optional) ----------------------------------------------------------------- +$funcRows = @() +if (-not $SkipFunctionCoverage) { + $fcDir = Join-Path $OutputDir 'FunctionCoverage' + try { + # Pre-merge here and call the reused script with -SkipMerge: its own merge passes the inputs + # via "@inputFiles" after an if-expression, which unwraps a SINGLE file to a scalar string and + # splats nothing (empty ). The pipeline always had 20 slices, so it never hit this. + $native = Join-Path $fcDir 'NativeMerge\merged-native.xml' + New-Item -ItemType Directory -Force (Split-Path $native) | Out-Null + & $dc merge @CoverageFiles -f xml -o $native --nologo 2>&1 | Select-Object -Last 1 | ForEach-Object { Info " $_" } + & (Join-Path $lib 'Get-FunctionCoverage.ps1') -CoverageFiles $CoverageFiles -OutputDir $fcDir -ExistingPerFileCsv $modCsv -SkipMerge | Out-Null + $fcCsv = Join-Path $fcDir 'corrected-per-function.csv' + if (Test-Path $fcCsv) { + $funcRows = Import-Csv $fcCsv | Where-Object { $_.File -match $ComponentPathRegex } | + Sort-Object File, @{e={[double]$_.LineCoveragePct}} + $funcRows | Export-Csv (Join-Path $OutputDir 'component-functions.csv') -NoTypeInformation + } + } catch { Write-Warning "function coverage failed: $_" } +} + +# ---- summary markdown ----------------------------------------------------------------------------- +$md = New-Object Text.StringBuilder +[void]$md.AppendLine("# $ComponentName code coverage - $RunLabel") +[void]$md.AppendLine() +[void]$md.AppendLine("Module: ``$Module.dll`` | Coverage file(s): $($CoverageFiles.Count) | Generated: $(Get-Date -Format 'yyyy-MM-dd HH:mm')") +[void]$md.AppendLine() +[void]$md.AppendLine("**$ComponentName total: $cov / $tot lines = $pct %** (whole module in this run: $($modSummary.CoveredLines) / $($modSummary.TotalLines) = $($modSummary.LineCoverage) %)") +if ($BaselinePerFileCsv) { + $bt = ($comp | Where-Object { $null -ne $_.BaselineTotal } | Measure-Object BaselineTotal -Sum).Sum + $bc = ($comp | Where-Object { $null -ne $_.BaselineTotal } | Measure-Object BaselineCovered -Sum).Sum + if ($bt) { [void]$md.AppendLine("`n$BaselineLabel for the same files: $bc / $bt = $([math]::Round(100.0*$bc/$bt,2)) %") } +} +[void]$md.AppendLine() +[void]$md.AppendLine("| File | Covered / Total | $RunLabel % | $BaselineLabel % |") +[void]$md.AppendLine('|---|---|---|---|') +foreach ($c in $comp) { + $bp = if ($null -ne $c.BaselineCoverage) { "$($c.BaselineCoverage) % ($($c.BaselineCovered)/$($c.BaselineTotal))" } else { 'n/a' } + [void]$md.AppendLine("| ``$($c.File)`` | $($c.CoveredLines) / $($c.TotalLines) | **$($c.LineCoverage) %** | $bp |") +} +if ($funcRows) { + $hand = $funcRows | Where-Object { $_.File -match '\\controls\\dev\\' + [regex]::Escape($ComponentName) + '\\[^\\]+\.cpp$' } + $unc = @($hand | Where-Object { [int]$_.CoveredLines -eq 0 }) + $part = @($hand | Where-Object { [int]$_.CoveredLines -gt 0 -and [int]$_.CoveredLines -lt [int]$_.TotalLines }) + [void]$md.AppendLine() + [void]$md.AppendLine("## Functions in $ComponentName .cpp files") + [void]$md.AppendLine("$(@($hand).Count) functions: $(@($hand).Count - $unc.Count - $part.Count) fully covered, $($part.Count) partially, $($unc.Count) not covered.") + if ($unc) { + [void]$md.AppendLine("`n**Not covered:**") + $unc | ForEach-Object { [void]$md.AppendLine("- ``$($_.Function)`` ($(Split-Path $_.File -Leaf), $($_.TotalLines) lines)") } + } + if ($part) { + [void]$md.AppendLine("`n**Partially covered (lowest first):**") + $part | Select-Object -First 25 | ForEach-Object { [void]$md.AppendLine("- ``$($_.Function)`` $($_.CoveredLines)/$($_.TotalLines) ($($_.LineCoveragePct) %)") } + } +} +[void]$md.AppendLine() +[void]$md.AppendLine('Files: `component-per-file.csv`, `component-functions.csv`, `TreeReport\coverage-tree.html`, `PerModule\`, `merged.cobertura.xml`.') +$md.ToString() | Set-Content (Join-Path $OutputDir 'coverage-summary.md') -Encoding UTF8 +Info "$ComponentName : $cov / $tot = $pct % -> $(Join-Path $OutputDir 'coverage-summary.md')" +[pscustomobject]@{ component = $ComponentName; coveredLines = $cov; totalLines = $tot; lineCoveragePct = $pct + summary = (Join-Path $OutputDir 'coverage-summary.md'); perFile = (Join-Path $OutputDir 'component-per-file.csv') } diff --git a/.github/skills/vm-component-coverage/scripts/lib/Build-CoverageTreeReport.ps1 b/.github/skills/vm-component-coverage/scripts/lib/Build-CoverageTreeReport.ps1 new file mode 100644 index 000000000..af116f49f --- /dev/null +++ b/.github/skills/vm-component-coverage/scripts/lib/Build-CoverageTreeReport.ps1 @@ -0,0 +1,455 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. +# +# Build-CoverageTreeReport.ps1 +# +# Renders a SINGLE, self-contained HTML file with a collapsible folder-tree view of code +# coverage - as opposed to reportgenerator's default `Html` report type, which emits one .html +# page PER SOURCE FILE/CLASS (thousands of files for a full WinUI build). This script performs +# no new coverage computation of its own - it is a pure rendering step on top of data already +# produced (and already verified to match Azure DevOps's own Code Coverage tab exactly) by +# Repair-CoverageDuplicates.ps1: `corrected-per-file.csv` and `corrected-summary.json`. +# +# OUTPUT: exactly one .html file. No external .css/.js/.svg assets, no per-file pages, no CDN +# dependencies - everything (styles, script, and the coverage data itself) is embedded inline, +# so the file can be opened directly from disk (or emailed, attached to a work item, etc.) with +# no network access and no companion folder. +# +# TREE UI: folders are collapsed by default at every level (only top-level root folders are +# visible on first load); clicking a folder row expands/collapses just that folder's immediate +# children (subfolders and files) one level at a time. Each folder shows a coverage percentage +# that is the SUM of covered/total lines across every file nested anywhere beneath it (computed +# bottom-up); each file shows its own exact percentage. Both use the same 3-tier ADO-style +# color scale: green >= 75%, yellow 40-75%, red < 40%. + +[CmdletBinding()] +param( + # Path to corrected-per-file.csv, as produced by Repair-CoverageDuplicates.ps1. + [Parameter(Mandatory = $true)] + [string]$InputCsv, + + # Path to corrected-summary.json (for the overall KPI header). Optional - if omitted or + # missing, the KPI header is computed from the per-file rows instead. + [string]$InputSummaryJson = "", + + # Folder to write the single HTML report into. Created if it doesn't exist. Kept separate + # from reportgenerator's own (multi-file) `html\` output folder by convention. + [Parameter(Mandatory = $true)] + [string]$OutputDir, + + # Report title shown at the top of the page. + [string]$Title = "Code Coverage Report", + + # Regex matched against the START of each file path and stripped before display (the + # "boilerplate" build-agent prefix). Default matches the OneBranch convention + # (e.g. C:\__w\1\s\, D:\a\3\s\ etc. - a drive letter, "__w" or "a", a numeric leg id, then + # "s\") case-insensitively, on either slash direction. If a given path doesn't match, it is + # left completely unchanged - this is a display-only trim, never a hard requirement. + [string]$StripPathPrefix = '^[A-Za-z]:[\\/]+(__w|a)[\\/]+\d+[\\/]+s[\\/]+', + + # Output file name. + [string]$OutputFileName = 'coverage-tree.html', + + # Name(s) of the instrumented product DLL(s) this coverage measures (e.g. + # "Microsoft.ui.xaml.dll"). Shown at the top of the report next to the title. If not + # supplied, falls back to the "InstrumentedModules" field in -InputSummaryJson (as written + # by Repair-CoverageDuplicates.ps1's -ModuleNames parameter); if that's also unavailable, + # the report simply omits the module line rather than guessing. + [string[]]$ModuleNames = @() +) + +$ErrorActionPreference = 'Stop' + +if (-not (Test-Path $InputCsv)) { + throw "Input CSV not found: $InputCsv" +} +New-Item -ItemType Directory -Force -Path $OutputDir | Out-Null + +Write-Host "Building single-file folder-tree coverage report from $InputCsv ..." +$sw = [System.Diagnostics.Stopwatch]::StartNew() + +$rows = Import-Csv $InputCsv +if ($rows.Count -eq 0) { + throw "No rows found in $InputCsv - nothing to render." +} + +$stripRegex = $null +if ($StripPathPrefix) { + try { $stripRegex = New-Object System.Text.RegularExpressions.Regex($StripPathPrefix, 'IgnoreCase') } + catch { Write-Host "WARNING: -StripPathPrefix is not a valid regex ('$StripPathPrefix'); paths will be shown unmodified. $($_.Exception.Message)" -ForegroundColor Yellow } +} + +# --------------------------------------------------------------------------------------------- +# Build an in-memory folder tree. Each node: @{ Name; IsFile; Covered; Total; Children (ordered +# dictionary, folders/files keyed by name) }. Folder Covered/Total are computed bottom-up after +# every file has been inserted. +# --------------------------------------------------------------------------------------------- +function New-TreeNode { + param([string]$Name, [bool]$IsFile) + [pscustomobject]@{ + Name = $Name + IsFile = $IsFile + Covered = 0 + Total = 0 + Children = [ordered]@{} + } +} + +$root = New-TreeNode -Name '' -IsFile $false +$strippedCount = 0 +$totalCount = 0 + +foreach ($row in $rows) { + $totalCount++ + $path = $row.File + if ($stripRegex -and $stripRegex.IsMatch($path)) { + $path = $stripRegex.Replace($path, '', 1) + $strippedCount++ + } + $path = $path -replace '/', '\' + $segments = $path.Split('\') | Where-Object { $_ -ne '' } + if ($segments.Count -eq 0) { continue } + + $covered = [int]$row.CoveredLines + $total = [int]$row.TotalLines + + $node = $root + for ($i = 0; $i -lt $segments.Count; $i++) { + $seg = $segments[$i] + $isLeaf = ($i -eq $segments.Count - 1) + if (-not $node.Children.Contains($seg)) { + $node.Children[$seg] = New-TreeNode -Name $seg -IsFile $isLeaf + } + $node = $node.Children[$seg] + } + # $node is now the leaf (file) node. + $node.Covered = $covered + $node.Total = $total +} + +Write-Host ("Stripped the boilerplate prefix from {0}/{1} paths." -f $strippedCount, $totalCount) + +# Bottom-up rollup: a folder's Covered/Total = sum of its children's (already-rolled-up) values. +function Get-Rollup { + param($Node) + if ($Node.IsFile) { return } + $c = 0; $t = 0 + foreach ($child in $Node.Children.Values) { + Get-Rollup -Node $child + $c += $child.Covered + $t += $child.Total + } + $Node.Covered = $c + $Node.Total = $t +} +Get-Rollup -Node $root + +# --------------------------------------------------------------------------------------------- +# Convert to a compact, JSON-serializable shape: folders sorted before files, then alphabetical. +# Keys are kept short (n/t/c/f/k) since this blob is embedded directly in the HTML and a full +# WinUI build can have several thousand files. +# --------------------------------------------------------------------------------------------- +function ConvertTo-JsonNode { + param($Node) + $pct = if ($Node.Total -gt 0) { [math]::Round(100.0 * $Node.Covered / $Node.Total, 2) } else { 0 } + $obj = [ordered]@{ + n = $Node.Name + c = $Node.Covered + t = $Node.Total + p = $pct + } + if ($Node.IsFile) { + $obj['f'] = 1 + } else { + $sortedChildren = $Node.Children.Values | Sort-Object @{Expression = { $_.IsFile } }, @{Expression = { $_.Name } } + $obj['k'] = @($sortedChildren | ForEach-Object { ConvertTo-JsonNode -Node $_ }) + } + return $obj +} + +$treeData = @($root.Children.Values | Sort-Object @{Expression = { $_.IsFile } }, @{Expression = { $_.Name } } | ForEach-Object { ConvertTo-JsonNode -Node $_ }) +$treeJson = $treeData | ConvertTo-Json -Depth 100 -Compress +if ($treeData.Count -eq 1) { + # ConvertTo-Json unwraps single-element arrays unless -AsArray is available (PS 6.1+); force array shape. + $treeJson = "[$treeJson]" +} + +# --------------------------------------------------------------------------------------------- +# Overall KPI header - prefer the already-computed summary (exact figures verified against ADO); +# fall back to summing the per-file rows if the summary file wasn't supplied/found. +# --------------------------------------------------------------------------------------------- +$summary = $null +if ($InputSummaryJson -and (Test-Path $InputSummaryJson)) { + $summary = Get-Content $InputSummaryJson -Raw | ConvertFrom-Json +} +if (-not $summary) { + $totalLines = ($rows | Measure-Object -Property TotalLines -Sum).Sum + $coveredLines = ($rows | Measure-Object -Property CoveredLines -Sum).Sum + $summary = [pscustomobject]@{ + Modules = $rows.Count + TotalLines = $totalLines + CoveredLines = $coveredLines + LineCoverage = if ($totalLines -gt 0) { [math]::Round(100.0 * $coveredLines / $totalLines, 2) } else { 0 } + } +} + +# Resolve which DLL(s) this report is measuring: an explicit -ModuleNames wins; otherwise fall +# back to the "InstrumentedModules" field already recorded in corrected-summary.json (as passed +# through from Get-PipelineCodeCoverage.ps1's -Module parameter); if neither is available the +# report simply omits the module line rather than guessing at a DLL name. +$resolvedModuleNames = @($ModuleNames) +if ($resolvedModuleNames.Count -eq 0 -and $summary.PSObject.Properties.Name -contains 'InstrumentedModules') { + $resolvedModuleNames = @($summary.InstrumentedModules) +} +$moduleDisplay = if ($resolvedModuleNames -and $resolvedModuleNames.Count -gt 0) { ($resolvedModuleNames -join ', ') } else { '' } + +# Make sure SUMMARY (embedded in the HTML for the KPI cards) reflects the resolved module list +# regardless of which path it came from (explicit -ModuleNames vs. corrected-summary.json). +if ($summary.PSObject.Properties.Name -contains 'InstrumentedModules') { + $summary.InstrumentedModules = @($resolvedModuleNames) +} else { + $summary | Add-Member -NotePropertyName 'InstrumentedModules' -NotePropertyValue @($resolvedModuleNames) +} + +$summaryJson = $summary | ConvertTo-Json -Compress + +# --------------------------------------------------------------------------------------------- +# HTML template. Single-quoted here-string (no PowerShell variable expansion) with placeholder +# tokens swapped in afterward - this keeps every '$' in the embedded JavaScript (template +# literals, etc.) completely literal and avoids any PowerShell-escaping gymnastics. +# --------------------------------------------------------------------------------------------- +$htmlTemplate = @' + + + + +__TITLE__ + + + +

__TITLE__

+__MODULE_LINE__ +
Generated __GENERATED_AT__ · boilerplate path prefix stripped from every entry below
+ +
+ +
+ ≥ 75% + 40–75% + < 40% + Click a folder row to expand/collapse it +
+ + + +
+ +
Self-contained report - no external files. Coverage data is bug-fixed for a verified duplicate-line defect in the native (dotnet-coverage / Microsoft.CodeCoverage.Console) merge engine; these numbers match the Azure DevOps Code Coverage tab.
+ + + + +'@ + +$outFile = Join-Path $OutputDir $OutputFileName +$moduleLineHtml = if ($moduleDisplay) { + '
Code coverage measured for: ' + [System.Net.WebUtility]::HtmlEncode($moduleDisplay) + '
' +} else { + '' +} +$html = $htmlTemplate.Replace('__TITLE__', [System.Net.WebUtility]::HtmlEncode($Title)) +$html = $html.Replace('__MODULE_LINE__', $moduleLineHtml) +$html = $html.Replace('__GENERATED_AT__', (Get-Date).ToString('yyyy-MM-dd HH:mm:ss')) +$html = $html.Replace('__TREE_JSON__', $treeJson) +$html = $html.Replace('__SUMMARY_JSON__', $summaryJson) + +Set-Content -Path $outFile -Value $html -Encoding UTF8 + +Write-Host ("`nWrote single-file tree report in {0:N1}s -> {1}" -f $sw.Elapsed.TotalSeconds, $outFile) +if ($moduleDisplay) { Write-Host (" Module(s): {0}" -f $moduleDisplay) } +Write-Host (" Overall: {0}% ({1} / {2} lines across {3} files)" -f $summary.LineCoverage, $summary.CoveredLines, $summary.TotalLines, $summary.Modules) diff --git a/.github/skills/vm-component-coverage/scripts/lib/Build-FunctionCoverageTreeReport.ps1 b/.github/skills/vm-component-coverage/scripts/lib/Build-FunctionCoverageTreeReport.ps1 new file mode 100644 index 000000000..be6c35ac3 --- /dev/null +++ b/.github/skills/vm-component-coverage/scripts/lib/Build-FunctionCoverageTreeReport.ps1 @@ -0,0 +1,540 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. +# +# Build-FunctionCoverageTreeReport.ps1 +# +# Renders a SINGLE, self-contained HTML file that extends Build-CoverageTreeReport.ps1's +# collapsible folder/file tree with one more level: expand a FILE row to reveal the individual +# C++ functions defined in it (from Get-FunctionCoverage.ps1's corrected-per-function.csv), each +# with its own covered/total lines and %. +# +# The folder/file levels (and their rollup percentages/coloring) are built from +# corrected-per-file.csv exactly like Build-CoverageTreeReport.ps1 - i.e. they still show the +# Azure-DevOps-verified, authoritative file/folder totals. The function level underneath a file +# is a separate, ADDITIONAL data source (corrected-per-function.csv) laid on top purely for +# drill-down - it is NOT summed into the file/folder rollup, and deliberately so: in C++, many +# distinct compiled functions (template instantiations, inlined helpers, macro expansions) can +# legitimately share the same physical source line, so summing per-function totals does not +# reproduce a file's true unique-line total (see Get-FunctionCoverage.ps1's header comment for +# the full explanation and a worked example). This report keeps both views honest by computing +# them independently and never mixing one into the other. +# +# PERFORMANCE: a full WinUI build can have 250k+ functions. Folder/file rows are built eagerly +# (same as Build-CoverageTreeReport.ps1 - a few thousand nodes, cheap), but each file's function +# list is only built into the DOM the first time that file row is expanded (lazy), so the report +# stays fast to load even though the underlying embedded JSON contains every function. +# +# OUTPUT: exactly one .html file - no external assets, works fully offline. +# +# EXAMPLE +# .\Build-FunctionCoverageTreeReport.ps1 ` +# -InputPerFileCsv CorrectedReport\corrected-per-file.csv ` +# -InputPerFunctionCsv FunctionCoverage\corrected-per-function.csv ` +# -InputSummaryJson CorrectedReport\corrected-summary.json ` +# -OutputDir FunctionCoverage\TreeReport ` +# -ModuleNames Microsoft.ui.xaml.dll + +[CmdletBinding()] +param( + # Path to corrected-per-file.csv, as produced by Repair-CoverageDuplicates.ps1 / + # Get-PipelineCodeCoverage.ps1. Drives the folder/file levels of the tree (authoritative + # totals - matches the Azure DevOps Code Coverage tab). + [Parameter(Mandatory = $true)] + [string]$InputPerFileCsv, + + # Path to corrected-per-function.csv, as produced by Get-FunctionCoverage.ps1. Drives the + # function-level drill-down nested under each file row. + [Parameter(Mandatory = $true)] + [string]$InputPerFunctionCsv, + + # Path to corrected-summary.json (for the overall KPI header). Optional - if omitted or + # missing, the KPI header is computed from the per-file rows instead. + [string]$InputSummaryJson = "", + + # Folder to write the single HTML report into. Created if it doesn't exist. + [Parameter(Mandatory = $true)] + [string]$OutputDir, + + # Report title shown at the top of the page. + [string]$Title = "Function Coverage Report", + + # Regex matched against the START of each file path and stripped before display - same + # convention/default as Build-CoverageTreeReport.ps1. Applied identically to both input CSVs + # so file paths line up between them. + [string]$StripPathPrefix = '^[A-Za-z]:[\\/]+(__w|a)[\\/]+\d+[\\/]+s[\\/]+', + + # Output file name. + [string]$OutputFileName = 'function-coverage-tree.html', + + # Name(s) of the instrumented product DLL(s) this coverage measures. Same semantics as + # Build-CoverageTreeReport.ps1's -ModuleNames. + [string[]]$ModuleNames = @() +) + +$ErrorActionPreference = 'Stop' + +if (-not (Test-Path $InputPerFileCsv)) { throw "Input per-file CSV not found: $InputPerFileCsv" } +if (-not (Test-Path $InputPerFunctionCsv)) { throw "Input per-function CSV not found: $InputPerFunctionCsv" } +New-Item -ItemType Directory -Force -Path $OutputDir | Out-Null + +Write-Host "Building single-file function-drill-down tree report..." +Write-Host " Per-file (authoritative): $InputPerFileCsv" +Write-Host " Per-function (drill-down): $InputPerFunctionCsv" +$sw = [System.Diagnostics.Stopwatch]::StartNew() + +$fileRows = Import-Csv $InputPerFileCsv +if ($fileRows.Count -eq 0) { throw "No rows found in $InputPerFileCsv - nothing to render." } +$functionRows = Import-Csv $InputPerFunctionCsv +Write-Host ("Loaded {0:N0} file row(s) and {1:N0} function row(s)." -f $fileRows.Count, $functionRows.Count) + +$stripRegex = $null +if ($StripPathPrefix) { + try { $stripRegex = New-Object System.Text.RegularExpressions.Regex($StripPathPrefix, 'IgnoreCase') } + catch { Write-Host "WARNING: -StripPathPrefix is not a valid regex ('$StripPathPrefix'); paths will be shown unmodified. $($_.Exception.Message)" -ForegroundColor Yellow } +} + +function Get-NormalizedPath { + param([string]$Path) + $p = $Path + if ($stripRegex -and $stripRegex.IsMatch($p)) { $p = $stripRegex.Replace($p, '', 1) } + return ($p -replace '/', '\') +} + +# --------------------------------------------------------------------------------------------- +# 1. Group corrected-per-function.csv by (normalized) file path, sorting each file's own +# function list worst-covered-first (most useful for spotting gaps) - ties broken by name. +# Kept as plain arrays of compact records; NOT summed into anything - purely for drill-down. +# --------------------------------------------------------------------------------------------- +Write-Host "Grouping functions by file..." +$functionsByFile = @{} +foreach ($fr in $functionRows) { + $key = Get-NormalizedPath -Path $fr.File + if (-not $functionsByFile.ContainsKey($key)) { $functionsByFile[$key] = New-Object System.Collections.Generic.List[object] } + $total = [int]$fr.TotalLines + $covered = [int]$fr.CoveredLines + $pct = if ($total -gt 0) { [math]::Round(100.0 * $covered / $total, 2) } else { 0 } + $displayName = if ($fr.Type -and $fr.Type -ne '') { "$($fr.Type)::$($fr.Function)" } else { $fr.Function } + $tooltip = "$($fr.Namespace)::$($fr.Type)::$($fr.Function)" -replace '^::+', '' + [void]$functionsByFile[$key].Add([pscustomobject]@{ + n = $displayName + q = $tooltip + c = $covered + t = $total + p = $pct + }) +} +foreach ($k in @($functionsByFile.Keys)) { + $functionsByFile[$k] = @($functionsByFile[$k] | Sort-Object p, n) +} +Write-Host ("Grouped functions for {0:N0} distinct file(s)." -f $functionsByFile.Count) + +# --------------------------------------------------------------------------------------------- +# 2. Build the folder/file tree from corrected-per-file.csv - identical logic/rollup to +# Build-CoverageTreeReport.ps1 - then attach each file node's function list (if any) as a +# separate 'fk' (function-kids) property, kept apart from folder 'k' children. +# --------------------------------------------------------------------------------------------- +function New-TreeNode { + param([string]$Name, [bool]$IsFile) + [pscustomobject]@{ + Name = $Name + IsFile = $IsFile + Covered = 0 + Total = 0 + Functions = $null + Children = [ordered]@{} + } +} + +$root = New-TreeNode -Name '' -IsFile $false +$strippedCount = 0 +$totalCount = 0 + +foreach ($row in $fileRows) { + $totalCount++ + $rawPath = $row.File + $path = Get-NormalizedPath -Path $rawPath + if ($path -ne $rawPath) { $strippedCount++ } + $segments = $path.Split('\') | Where-Object { $_ -ne '' } + if ($segments.Count -eq 0) { continue } + + $covered = [int]$row.CoveredLines + $total = [int]$row.TotalLines + + $node = $root + for ($i = 0; $i -lt $segments.Count; $i++) { + $seg = $segments[$i] + $isLeaf = ($i -eq $segments.Count - 1) + if (-not $node.Children.Contains($seg)) { + $node.Children[$seg] = New-TreeNode -Name $seg -IsFile $isLeaf + } + $node = $node.Children[$seg] + } + $node.Covered = $covered + $node.Total = $total + $fns = $null + if ($functionsByFile.ContainsKey($path)) { $fns = $functionsByFile[$path] } + $node.Functions = $fns +} + +Write-Host ("Stripped the boilerplate prefix from {0}/{1} file paths." -f $strippedCount, $totalCount) + +function Get-Rollup { + param($Node) + if ($Node.IsFile) { return } + $c = 0; $t = 0 + foreach ($child in $Node.Children.Values) { + Get-Rollup -Node $child + $c += $child.Covered + $t += $child.Total + } + $Node.Covered = $c + $Node.Total = $t +} +Get-Rollup -Node $root + +function ConvertTo-JsonNode { + param($Node) + $pct = if ($Node.Total -gt 0) { [math]::Round(100.0 * $Node.Covered / $Node.Total, 2) } else { 0 } + $obj = [ordered]@{ + n = $Node.Name + c = $Node.Covered + t = $Node.Total + p = $pct + } + if ($Node.IsFile) { + $obj['f'] = 1 + if ($Node.Functions -and $Node.Functions.Count -gt 0) { $obj['fk'] = $Node.Functions } + } else { + $sortedChildren = $Node.Children.Values | Sort-Object @{Expression = { $_.IsFile } }, @{Expression = { $_.Name } } + $obj['k'] = @($sortedChildren | ForEach-Object { ConvertTo-JsonNode -Node $_ }) + } + return $obj +} + +$treeData = @($root.Children.Values | Sort-Object @{Expression = { $_.IsFile } }, @{Expression = { $_.Name } } | ForEach-Object { ConvertTo-JsonNode -Node $_ }) +$treeJson = $treeData | ConvertTo-Json -Depth 100 -Compress +if ($treeData.Count -eq 1) { $treeJson = "[$treeJson]" } + +# --------------------------------------------------------------------------------------------- +# 3. KPI header - file-level KPIs from the (already-verified) summary/per-file rows, plus new +# function-level KPIs computed straight from corrected-per-function.csv. +# --------------------------------------------------------------------------------------------- +$summary = $null +if ($InputSummaryJson -and (Test-Path $InputSummaryJson)) { + $summary = Get-Content $InputSummaryJson -Raw | ConvertFrom-Json +} +if (-not $summary) { + $totalLines = ($fileRows | Measure-Object -Property TotalLines -Sum).Sum + $coveredLines = ($fileRows | Measure-Object -Property CoveredLines -Sum).Sum + $summary = [pscustomobject]@{ + Modules = $fileRows.Count + TotalLines = $totalLines + CoveredLines = $coveredLines + LineCoverage = if ($totalLines -gt 0) { [math]::Round(100.0 * $coveredLines / $totalLines, 2) } else { 0 } + } +} + +$totalFunctions = $functionRows.Count +$functionsWithCoverage = @($functionRows | Where-Object { [int]$_.CoveredLines -gt 0 }).Count +$functionsFullyCovered = @($functionRows | Where-Object { [int]$_.TotalLines -gt 0 -and [int]$_.CoveredLines -eq [int]$_.TotalLines }).Count + +$resolvedModuleNames = @($ModuleNames) +if ($resolvedModuleNames.Count -eq 0 -and $summary.PSObject.Properties.Name -contains 'InstrumentedModules') { + $resolvedModuleNames = @($summary.InstrumentedModules) +} +$moduleDisplay = if ($resolvedModuleNames -and $resolvedModuleNames.Count -gt 0) { ($resolvedModuleNames -join ', ') } else { '' } + +$summaryObj = [ordered]@{ + LineCoverage = $summary.LineCoverage + CoveredLines = $summary.CoveredLines + TotalLines = $summary.TotalLines + Modules = $summary.Modules + TotalFunctions = $totalFunctions + FunctionsWithCoverage = $functionsWithCoverage + FunctionsFullyCovered = $functionsFullyCovered +} +$summaryJson = ($summaryObj | ConvertTo-Json -Compress) + +# --------------------------------------------------------------------------------------------- +# HTML template. Single-quoted here-string (no PowerShell variable expansion). +# --------------------------------------------------------------------------------------------- +$htmlTemplate = @' + + + + +__TITLE__ + + + +

__TITLE__

+__MODULE_LINE__ +
Generated __GENERATED_AT__ · boilerplate path prefix stripped from every entry below
+ +
+ +
Folder/file percentages and line totals are the authoritative, Azure-DevOps-verified numbers. Expand a file to see its individual functions - each function's own covered/total lines are shown for that function alone, and are not summed into the file total above it (in C++, distinct functions such as template instantiations can legitimately share the same physical source line, so a naive sum would double-count).
+ +
+ ≥ 75% + 40–75% + < 40% + ƒ = function (click a file with a caret to reveal its functions) + Search filters folders/files only +
+ + + +
+ +
Self-contained report - no external files. File/folder coverage matches the Azure DevOps Code Coverage tab exactly. Function-level detail comes from the coverage merge engine's native (non-Cobertura) output format, which preserves per-function boundaries that Cobertura collapses away.
+ + + + +'@ + +$outFile = Join-Path $OutputDir $OutputFileName +$moduleLineHtml = if ($moduleDisplay) { + '
Code coverage measured for: ' + [System.Net.WebUtility]::HtmlEncode($moduleDisplay) + '
' +} else { + '' +} +$html = $htmlTemplate.Replace('__TITLE__', [System.Net.WebUtility]::HtmlEncode($Title)) +$html = $html.Replace('__MODULE_LINE__', $moduleLineHtml) +$html = $html.Replace('__GENERATED_AT__', (Get-Date).ToString('yyyy-MM-dd HH:mm:ss')) +$html = $html.Replace('__TREE_JSON__', $treeJson) +$html = $html.Replace('__SUMMARY_JSON__', $summaryJson) + +Set-Content -Path $outFile -Value $html -Encoding UTF8 + +Write-Host ("`nWrote single-file function-drill-down tree report in {0:N1}s -> {1} ({2:N1} MB)" -f $sw.Elapsed.TotalSeconds, $outFile, ((Get-Item $outFile).Length / 1MB)) +if ($moduleDisplay) { Write-Host (" Module(s): {0}" -f $moduleDisplay) } +Write-Host (" File-level: {0}% ({1} / {2} lines across {3} files)" -f $summary.LineCoverage, $summary.CoveredLines, $summary.TotalLines, $summary.Modules) +Write-Host (" Functions: {0:N0} total, {1:N0} with any coverage, {2:N0} fully covered" -f $totalFunctions, $functionsWithCoverage, $functionsFullyCovered) diff --git a/.github/skills/vm-component-coverage/scripts/lib/Get-FunctionCoverage.ps1 b/.github/skills/vm-component-coverage/scripts/lib/Get-FunctionCoverage.ps1 new file mode 100644 index 000000000..d01d38654 --- /dev/null +++ b/.github/skills/vm-component-coverage/scripts/lib/Get-FunctionCoverage.ps1 @@ -0,0 +1,350 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. +# +# Get-FunctionCoverage.ps1 +# +# Produces a FUNCTION-LEVEL coverage breakdown ("which functions in this file were exercised by +# the tests, and by how much") - one row per C++ function/overload, not just one row per source +# file. This is a companion to (not a replacement for) Repair-CoverageDuplicates.ps1, which only +# ever reconstructs FILE-level totals because Cobertura (the format that script's input is in) +# collapses per-function detail down to one per source file. To get real function +# boundaries this script uses a different raw input: the coverage merge engine's native "xml" +# output format (`dotnet-coverage merge -f xml` / `Microsoft.CodeCoverage.Console merge -f xml`), +# which - unlike Cobertura - keeps one element per C++ function/template instantiation +# (name, namespace, containing class/type, and its own per-line hit/miss list), with a +# table mapping each line back to a real source path. +# +# THIS SCRIPT WORKS DIRECTLY FROM THE RAW PER-SLICE .coverage FILES - it does NOT need the +# product DLL/PDB. That symbol info (function names, namespaces, source file + line numbers) is +# already embedded in each .coverage file at collection time; this was explicitly verified by +# stripping the DLL/PDB out of a test run folder and re-merging - the function-level XML still +# resolved perfectly. +# +# PER-FUNCTION DEDUPLICATION: within a single function's own list, this script keeps the +# max covered-state (yes > partial > no) seen per (source file, line number) - guarding against +# the same duplicate-range-on-merge defect Repair-CoverageDuplicates.ps1 fixes for Cobertura, in +# case it ever resurfaces for a given function. In practice, `dotnet-coverage merge -f xml` was +# observed to already correctly union same-function-id ranges across contributing slices without +# duplication (a real build's `AccessKey.cpp`, merged from all 20 slices, summed its 8 functions' +# corrected totals to exactly 52 - matching that file's independently-verified true line count) - +# so this per-function dedup is mostly a defensive safety net, not something that was seen to +# fire heavily in practice. +# +# IMPORTANT NUANCE - per-function totals do NOT sum to the file's true unique-line total, and +# THAT IS EXPECTED, NOT A BUG: in C++, many distinct COMPILED functions can legitimately share +# the same physical source line - template instantiations (e.g. `_tlgCreate1Sz` and +# `_tlgCreate1Sz` are two separate compiled functions that both report the identical 9 +# header lines of their shared template body), inlined helpers, and macro expansions are the +# common causes. Verified on a real build: TraceLoggingProvider.h has 163 distinct instantiated +# functions whose own totals sum to 593 lines, even though the header itself has far fewer +# physical lines - each instantiation is legitimately "using" the same textual lines as its +# siblings. Because of this, DO NOT use this script's per-function rows to re-derive a file's +# true aggregate line coverage - use the existing, Azure-DevOps-verified `corrected-per-file.csv` +# (from Repair-CoverageDuplicates.ps1 / Get-PipelineCodeCoverage.ps1) for that; pass it via +# -ExistingPerFileCsv to have this script merge those authoritative file totals alongside the new +# per-function/function-count breakdown in corrected-per-file-function-summary.csv. +# +# OUTPUT (under -OutputDir) +# NativeMerge\merged-native.xml - raw dotnet-coverage -f xml merge. +# corrected-per-function.csv - one row per function: File, Namespace, Type, Function, +# TotalLines, CoveredLines, FullyCoveredLines, +# PartiallyCoveredLines, NotCoveredLines, +# LineCoveragePct (all "own view" totals for that one +# compiled function - see the nuance above before +# summing this column across functions). +# Sorted by File then Function. +# corrected-per-file-function-summary.csv - one row per source file: TotalFunctions, +# FunctionsWithAnyCoverage, FunctionsFullyCovered, +# FunctionsNotCovered - plus, only when +# -ExistingPerFileCsv is supplied, that file's +# authoritative FileTotalLines/FileCoveredLines/ +# FileLineCoveragePct joined in from corrected-per-file.csv. +# +# EXAMPLE (reusing a prior Get-PipelineCodeCoverage.ps1 run's already-downloaded slices, joining +# in the authoritative per-file totals it already computed) +# .\Get-FunctionCoverage.ps1 -SliceDir C:\Coverage\154383932\SliceCoverage ` +# -OutputDir C:\Coverage\154383932\FunctionCoverage ` +# -ExistingPerFileCsv C:\Coverage\154383932\CorrectedReport\corrected-per-file.csv +# +# EXAMPLE (explicit list of .coverage files) +# .\Get-FunctionCoverage.ps1 -CoverageFiles file1.coverage,file2.coverage -OutputDir .\out + +[CmdletBinding(DefaultParameterSetName = 'FromSliceDir')] +param( + # Directory to search recursively for raw *.coverage files (e.g. the SliceCoverage folder + # that Get-PipelineCodeCoverage.ps1 populates). All matches are merged together. + [Parameter(Mandatory = $true, ParameterSetName = 'FromSliceDir')] + [string]$SliceDir, + + # Explicit list of raw .coverage files to merge (alternative to -SliceDir). + [Parameter(Mandatory = $true, ParameterSetName = 'FromFiles')] + [string[]]$CoverageFiles, + + # Where to write outputs. Created if it doesn't exist. + [Parameter(Mandatory = $true)] + [string]$OutputDir, + + # Explicit path to dotnet-coverage / Microsoft.CodeCoverage.Console.exe. Auto-resolved + # (dotnet-coverage on PATH, else VS's Microsoft.CodeCoverage.Console.exe, else auto-installs + # dotnet-coverage) if not supplied. + [string]$CoverageToolPath = "", + + # Reuse an already-produced NativeMerge\merged-native.xml from a prior run instead of + # re-merging (useful for iterating on the dedup/CSV logic without re-running the - often + # multi-minute - merge step). + [switch]$SkipMerge, + + # Count a "partially covered" line (some but not all blocks on that line executed - common + # with multiple statements/branches per line in C++) as covered when computing CoveredLines + # and LineCoveragePct. On by default (matches the common-sense "was this line touched at + # all" reading). Fully-covered and partially-covered are still also broken out into their + # own separate columns regardless of this switch, so no information is lost either way. + [bool]$TreatPartialAsCovered = $true, + + # Keep NativeMerge\merged-native.xml after the run instead of leaving it (it is NOT deleted + # by default - unlike Get-PipelineCodeCoverage.ps1's small per-slice temp zips, this file is + # the expensive-to-regenerate one, so it is kept unless this switch is explicitly reversed by + # passing -KeepIntermediateXml:$false). + [bool]$KeepIntermediateXml = $true, + + # Optional path to an existing corrected-per-file.csv (as produced by + # Repair-CoverageDuplicates.ps1 / Get-PipelineCodeCoverage.ps1). When supplied, that file's + # already Azure-DevOps-verified per-file TotalLines/CoveredLines/LineCoverage are joined into + # corrected-per-file-function-summary.csv as FileTotalLines/FileCoveredLines/ + # FileLineCoveragePct, alongside this script's own function-count columns. Recommended - + # without it, the summary only has function counts (no line totals), by design, to avoid + # presenting a second, non-equivalent "file total" next to the one everyone already trusts + # (see the "IMPORTANT NUANCE" note above). + [string]$ExistingPerFileCsv = "" +) + +$ErrorActionPreference = 'Stop' + +$nativeMergeDir = Join-Path $OutputDir 'NativeMerge' +New-Item -ItemType Directory -Force -Path $OutputDir, $nativeMergeDir | Out-Null +$nativeXmlPath = Join-Path $nativeMergeDir 'merged-native.xml' + +# --------------------------------------------------------------------------------------------- +# 1. Resolve a coverage merge tool (same resolution order as Get-PipelineCodeCoverage.ps1). +# --------------------------------------------------------------------------------------------- +function Resolve-CoverageTool { + param([string]$Explicit) + if ($Explicit -and (Test-Path $Explicit)) { return $Explicit } + $dc = Get-Command 'dotnet-coverage' -ErrorAction SilentlyContinue + if ($dc) { return $dc.Source } + $vs = Get-ChildItem -Path @( + "${env:ProgramFiles}\Microsoft Visual Studio", + "${env:ProgramFiles(x86)}\Microsoft Visual Studio" + ) -Recurse -Filter 'Microsoft.CodeCoverage.Console.exe' -ErrorAction SilentlyContinue | Select-Object -First 1 + if ($vs) { return $vs.FullName } + Write-Host "dotnet-coverage not found; installing the global tool..." + & dotnet tool install --global dotnet-coverage --add-source https://api.nuget.org/v3/index.json --ignore-failed-sources | Write-Host + $env:PATH = "$env:PATH;$env:USERPROFILE\.dotnet\tools" + $dc = Get-Command 'dotnet-coverage' -ErrorAction SilentlyContinue + if ($dc) { return $dc.Source } + throw "Could not find or install a code coverage merge tool (dotnet-coverage or Microsoft.CodeCoverage.Console.exe)." +} + +# --------------------------------------------------------------------------------------------- +# 2. Merge raw .coverage files into the native (function-granular) xml format. +# --------------------------------------------------------------------------------------------- +if (-not $SkipMerge) { + $inputFiles = if ($PSCmdlet.ParameterSetName -eq 'FromSliceDir') { + Write-Host "=== Discovering raw .coverage files under $SliceDir ===" + @(Get-ChildItem $SliceDir -Recurse -Filter '*.coverage' -ErrorAction SilentlyContinue | Select-Object -ExpandProperty FullName) + } else { + @($CoverageFiles) + } + if ($inputFiles.Count -eq 0) { + throw "No .coverage files found (checked $(if ($PSCmdlet.ParameterSetName -eq 'FromSliceDir') { $SliceDir } else { '-CoverageFiles list' }))." + } + Write-Host "Merging $($inputFiles.Count) raw .coverage file(s) into the native function-granular xml format..." + Write-Host "(This does NOT require the product DLL/PDB - function/line symbol info is already embedded in each .coverage file.)" + + $covTool = Resolve-CoverageTool -Explicit $CoverageToolPath + Write-Host "Using: $covTool" + + $sw = [System.Diagnostics.Stopwatch]::StartNew() + & $covTool merge @inputFiles -f xml -o $nativeXmlPath --nologo | Write-Host + if ($LASTEXITCODE -ne 0) { throw "Native xml merge failed (exit $LASTEXITCODE)." } + Write-Host ("Merge complete in {0:N1}s -> {1} ({2:N0} MB)" -f $sw.Elapsed.TotalSeconds, $nativeXmlPath, ((Get-Item $nativeXmlPath).Length / 1MB)) + Write-Host "NOTE: this raw merged file still has the duplicate-range bug - do not read its / line/block-rate attributes directly; this script re-derives corrected values below." +} else { + if (-not (Test-Path $nativeXmlPath)) { throw "-SkipMerge was set but $nativeXmlPath does not exist." } + Write-Host "=== -SkipMerge set: reusing existing $nativeXmlPath ===" +} + +# --------------------------------------------------------------------------------------------- +# 3. Stream-parse the native xml once, deduplicating as we go: +# - source_files: source_id -> file path +# - functions: function id -> aggregated { Name, Namespace, TypeName, per-(sourceId,line) +# max covered-state, per-sourceId line-count (to pick the function's "primary" file) } +# A single forward-only XmlReader pass keeps this feasible on multi-hundred-MB inputs without +# loading a full DOM. +# --------------------------------------------------------------------------------------------- +Write-Host "`n=== Parsing and deduplicating $nativeXmlPath ===" +$sw = [System.Diagnostics.Stopwatch]::StartNew() + +Add-Type -Language CSharp -TypeDefinition @" +using System.Collections.Generic; +public class FuncAgg { + public string Name; + public string Namespace; + public string TypeName; + // "sourceId|line" -> state (2=yes, 1=partial, 0=no) + public Dictionary LineStates = new Dictionary(); + // sourceId -> count of distinct lines seen for that source, used to pick the "primary" file. + public Dictionary SourceLineCounts = new Dictionary(); +} +"@ + +$sourceFiles = New-Object 'System.Collections.Generic.Dictionary[string,string]' +$functions = New-Object 'System.Collections.Generic.Dictionary[string,FuncAgg]' + +$reader = [System.Xml.XmlReader]::Create($nativeXmlPath) +$currentFunc = $null +$rawFunctionElementCount = 0 +$rawRangeElementCount = 0 +try { + while ($reader.Read()) { + if ($reader.NodeType -ne [System.Xml.XmlNodeType]::Element) { continue } + + switch ($reader.Name) { + 'source_file' { + $id = $reader.GetAttribute('id') + $path = $reader.GetAttribute('path') + if ($id -and -not $sourceFiles.ContainsKey($id)) { $sourceFiles[$id] = $path } + } + 'function' { + $rawFunctionElementCount++ + $id = $reader.GetAttribute('id') + $agg = $null + if (-not $functions.TryGetValue($id, [ref]$agg)) { + $agg = New-Object FuncAgg + $agg.Name = $reader.GetAttribute('name') + $agg.Namespace = $reader.GetAttribute('namespace') + $agg.TypeName = $reader.GetAttribute('type_name') + $functions[$id] = $agg + } + $currentFunc = $agg + } + 'range' { + $rawRangeElementCount++ + if (-not $currentFunc) { continue } + $sid = $reader.GetAttribute('source_id') + $sln = [int]$reader.GetAttribute('start_line') + $eln = [int]$reader.GetAttribute('end_line') + $cov = $reader.GetAttribute('covered') + $state = if ($cov -eq 'yes') { 2 } elseif ($cov -eq 'partial') { 1 } else { 0 } + for ($ln = $sln; $ln -le $eln; $ln++) { + $key = "$sid|$ln" + $existing = 0 + if ($currentFunc.LineStates.TryGetValue($key, [ref]$existing)) { + if ($state -gt $existing) { $currentFunc.LineStates[$key] = $state } + } else { + $currentFunc.LineStates[$key] = $state + $cnt = 0 + [void]$currentFunc.SourceLineCounts.TryGetValue($sid, [ref]$cnt) + $currentFunc.SourceLineCounts[$sid] = $cnt + 1 + } + } + } + } + } +} finally { + $reader.Close() +} +Write-Host ("Parsed {0:N0} raw elements ({1:N0} unique) and {2:N0} raw elements in {3:N1}s." -f $rawFunctionElementCount, $functions.Count, $rawRangeElementCount, $sw.Elapsed.TotalSeconds) + +# --------------------------------------------------------------------------------------------- +# 4. Compute corrected per-function totals, attributing each function to its "primary" file +# (whichever source_id contributed the most distinct lines) and only counting that file's +# lines toward the function's own totals - this keeps a constructor's one stray reference to +# a header's default-member-initializer line, for example, from polluting its own .cpp totals. +# --------------------------------------------------------------------------------------------- +Write-Host "`n=== Computing corrected per-function totals ===" +$perFunctionRows = New-Object System.Collections.Generic.List[object] +foreach ($kvp in $functions.GetEnumerator()) { + $agg = $kvp.Value + if ($agg.SourceLineCounts.Count -eq 0) { continue } + $primarySourceId = ($agg.SourceLineCounts.GetEnumerator() | Sort-Object -Property Value -Descending | Select-Object -First 1).Key + $filePath = $null + if (-not $sourceFiles.TryGetValue($primarySourceId, [ref]$filePath)) { $filePath = "" } + + $total = 0; $fully = 0; $partial = 0 + foreach ($entry in $agg.LineStates.GetEnumerator()) { + if (-not $entry.Key.StartsWith("$primarySourceId|")) { continue } + $total++ + if ($entry.Value -eq 2) { $fully++ } + elseif ($entry.Value -eq 1) { $partial++ } + } + $notCovered = $total - $fully - $partial + $covered = if ($TreatPartialAsCovered) { $fully + $partial } else { $fully } + $pct = if ($total -gt 0) { [math]::Round(100.0 * $covered / $total, 2) } else { 0 } + + $perFunctionRows.Add([pscustomobject]@{ + File = $filePath + Namespace = $agg.Namespace + Type = $agg.TypeName + Function = $agg.Name + TotalLines = $total + CoveredLines = $covered + FullyCoveredLines = $fully + PartiallyCoveredLines = $partial + NotCoveredLines = $notCovered + LineCoveragePct = $pct + }) +} +Write-Host "Computed totals for $($perFunctionRows.Count) function(s)." + +# --------------------------------------------------------------------------------------------- +# 5. Roll up to a per-file function summary. This intentionally reports FUNCTION COUNTS only +# (not a re-derived line total - see the "IMPORTANT NUANCE" note at the top of this script for +# why summing per-function line totals is not a valid stand-in for a file's true unique-line +# total in C++). If -ExistingPerFileCsv was supplied, that already-verified file-level total +# is joined in alongside the function counts. +# --------------------------------------------------------------------------------------------- +Write-Host "`n=== Rolling up to per-file function summary ===" +$existingPerFile = $null +if ($ExistingPerFileCsv) { + if (-not (Test-Path $ExistingPerFileCsv)) { throw "-ExistingPerFileCsv path not found: $ExistingPerFileCsv" } + $existingPerFile = @{} + foreach ($row in (Import-Csv $ExistingPerFileCsv)) { $existingPerFile[$row.File] = $row } + Write-Host "Joining authoritative file totals from $ExistingPerFileCsv ($($existingPerFile.Count) file(s))." +} + +$perFileSummary = $perFunctionRows | Group-Object -Property File | ForEach-Object { + $rows = $_.Group + $row = [ordered]@{ + File = $_.Name + TotalFunctions = $rows.Count + FunctionsWithAnyCoverage = ($rows | Where-Object { $_.CoveredLines -gt 0 }).Count + FunctionsFullyCovered = ($rows | Where-Object { $_.TotalLines -gt 0 -and $_.CoveredLines -eq $_.TotalLines }).Count + FunctionsNotCovered = ($rows | Where-Object { $_.CoveredLines -eq 0 }).Count + } + if ($existingPerFile) { + $match = if ($existingPerFile.ContainsKey($_.Name)) { $existingPerFile[$_.Name] } else { $null } + $row['FileTotalLines'] = if ($match) { $match.TotalLines } else { '' } + $row['FileCoveredLines'] = if ($match) { $match.CoveredLines } else { '' } + $row['FileLineCoveragePct'] = if ($match) { $match.LineCoverage } else { '' } + } + [pscustomobject]$row +} + +# --------------------------------------------------------------------------------------------- +# 6. Write outputs. +# --------------------------------------------------------------------------------------------- +$perFunctionCsvPath = Join-Path $OutputDir 'corrected-per-function.csv' +$perFileSummaryCsvPath = Join-Path $OutputDir 'corrected-per-file-function-summary.csv' + +$perFunctionRows | Sort-Object File, Function | Export-Csv $perFunctionCsvPath -NoTypeInformation +$perFileSummary | Sort-Object File | Export-Csv $perFileSummaryCsvPath -NoTypeInformation + +if (-not $KeepIntermediateXml) { + Remove-Item $nativeXmlPath -Force -ErrorAction SilentlyContinue +} + +Write-Host "`n=== Done ===" +Write-Host " $perFunctionCsvPath" +Write-Host " $perFileSummaryCsvPath" +if ($KeepIntermediateXml) { Write-Host " $nativeXmlPath (kept; pass -KeepIntermediateXml `$false to discard)" } diff --git a/.github/skills/vm-component-coverage/scripts/lib/Repair-CoverageDuplicates.ps1 b/.github/skills/vm-component-coverage/scripts/lib/Repair-CoverageDuplicates.ps1 new file mode 100644 index 000000000..5a4a30140 --- /dev/null +++ b/.github/skills/vm-component-coverage/scripts/lib/Repair-CoverageDuplicates.ps1 @@ -0,0 +1,231 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. +# +# Repair-CoverageDuplicates.ps1 +# +# Fixes a real, verified defect in Microsoft's native (C++ static-instrumentation) code coverage +# merge engine - the same engine backing BOTH the `dotnet-coverage` global tool and Visual +# Studio's bundled `Microsoft.CodeCoverage.Console.exe`. +# +# THE BUG: when ` merge -f cobertura` merges multiple raw .coverage +# files that all instrument the SAME native module (e.g. one module instrumented once, then +# exercised by N separate collector processes on N separate machines - exactly what happens when +# a test pass is split into parallel Helix/lab slices with one collector per slice), the merge +# does NOT consolidate matching (file, line-number) entries into one. Instead it appends a +# fresh copy of that class's elements per contributing input file. This was verified to +# be 100% systemic (every single element in a real merged report exhibited it) and to +# exist even in Visual Studio's own native (non-Cobertura) merge output - so it is a merge-time +# defect, not a Cobertura-serialization-only quirk. +# +# EFFECT: any tool that iterates every element and sums them (dotnet-coverage's own +# printed merge summary, reportgenerator's TextSummary, etc.) overcounts both total and covered +# lines, understating the true coverage percentage. Azure DevOps's own "Code Coverage" build tab +# is NOT affected, because it deduplicates by (file, line-number) when it renders - this script +# reproduces that same deduplication so a locally-computed number matches the ADO tab exactly. +# (Verified: deduplicating a real build's merged.cobertura.xml this way reproduced the ADO tab's +# 4,500 files / 451,305 total lines / 337,879 covered lines / 74.87% exactly.) +# +# THE FIX (this script): parse the buggy merged Cobertura XML, and for every unique source file +# (grouping across ALL elements that map to it - some files have many classes, e.g. one +# per C++ template instantiation, that all reference the same physical source lines), keep only +# one entry per unique line number (hits = max across duplicates). Emit: +# 1. corrected-summary.json / corrected-summary.csv - overall + per-file corrected totals. +# 2. corrected.cobertura.xml - a schema-valid Cobertura file (one per source file, +# already deduplicated) that any standard Cobertura consumer (reportgenerator, ADO's own +# publish task, etc.) will read back with the SAME correct numbers. +# +# This script is self-contained and works on ANY Cobertura XML produced by dotnet-coverage / +# Microsoft.CodeCoverage.Console's merge - not just ones produced by the sibling +# Get-PipelineCodeCoverage.ps1 script in this skill. + +[CmdletBinding()] +param( + # Path to the (buggy, duplicate-line) merged Cobertura XML to repair. + [Parameter(Mandatory = $true)] + [string]$InputCobertura, + + # Where to write corrected-summary.json/.csv and corrected.cobertura.xml. + [Parameter(Mandatory = $true)] + [string]$OutputDir, + + # Name(s) of the product DLL(s) that were instrumented to produce this coverage data (e.g. + # "Microsoft.ui.xaml.dll", or multiple space-separated names if more than one module was + # instrumented). Purely descriptive - recorded in corrected-summary.json's + # "InstrumentedModules" field and used as the in the corrected Cobertura + # output, so downstream reports (e.g. Build-CoverageTreeReport.ps1's HTML) can show which + # binary this coverage percentage actually measures. Optional - falls back to a generic + # placeholder if not supplied. + [string[]]$ModuleNames = @() +) + +$ErrorActionPreference = 'Stop' +Add-Type -AssemblyName System.Xml + +if (-not (Test-Path $InputCobertura)) { + throw "Input Cobertura file not found: $InputCobertura" +} +New-Item -ItemType Directory -Force -Path $OutputDir | Out-Null + +Write-Host "Parsing and deduplicating $InputCobertura ..." +Write-Host "(This streams the XML rather than loading a full DOM, so large multi-hundred-MB merges are fine.)" +$sw = [System.Diagnostics.Stopwatch]::StartNew() + +# fileName -> Dictionary (hits = max seen across all duplicate/class entries) +$fileLineMap = New-Object 'System.Collections.Generic.Dictionary[string,System.Collections.Generic.Dictionary[int,int]]' +# Preserve first-seen file order for stable, readable output. +$fileOrder = New-Object 'System.Collections.Generic.List[string]' + +$reader = [System.Xml.XmlReader]::Create($InputCobertura) +$currentDict = $null +$classElementCount = 0 +$rawLineElementCount = 0 +try { + while ($reader.Read()) { + if ($reader.NodeType -eq [System.Xml.XmlNodeType]::Element -and $reader.Name -eq 'class') { + $classElementCount++ + $fn = $reader.GetAttribute('filename') + if (-not $fn) { $fn = $reader.GetAttribute('name') } + if (-not $fileLineMap.TryGetValue($fn, [ref]$currentDict)) { + $currentDict = New-Object 'System.Collections.Generic.Dictionary[int,int]' + $fileLineMap[$fn] = $currentDict + $fileOrder.Add($fn) + } + } + elseif ($reader.NodeType -eq [System.Xml.XmlNodeType]::Element -and $reader.Name -eq 'line') { + $rawLineElementCount++ + $num = [int]$reader.GetAttribute('number') + $hits = [int]$reader.GetAttribute('hits') + $existing = 0 + if ($currentDict.TryGetValue($num, [ref]$existing)) { + if ($hits -gt $existing) { $currentDict[$num] = $hits } + } else { + $currentDict[$num] = $hits + } + } + } +} finally { + $reader.Close() +} + +Write-Host ("Parsed {0} raw elements and {1} raw elements in {2:N1}s." -f $classElementCount, $rawLineElementCount, $sw.Elapsed.TotalSeconds) + +# Aggregate corrected per-file and overall totals. +$perFile = New-Object System.Collections.Generic.List[object] +$overallTotal = 0 +$overallCovered = 0 +foreach ($fn in $fileOrder) { + $dict = $fileLineMap[$fn] + $total = $dict.Count + $covered = 0 + foreach ($h in $dict.Values) { if ($h -gt 0) { $covered++ } } + $overallTotal += $total + $overallCovered += $covered + $perFile.Add([pscustomobject]@{ + File = $fn + CoveredLines = $covered + TotalLines = $total + LineCoverage = if ($total -gt 0) { [math]::Round(100.0 * $covered / $total, 2) } else { 0 } + }) +} + +$overallRate = if ($overallTotal -gt 0) { $overallCovered / $overallTotal } else { 0 } +$moduleDisplayName = if ($ModuleNames -and $ModuleNames.Count -gt 0) { $ModuleNames -join ', ' } else { '(not specified)' } + +$summary = [pscustomobject]@{ + Modules = $fileOrder.Count + TotalLines = $overallTotal + CoveredLines = $overallCovered + LineCoverage = [math]::Round(100.0 * $overallRate, 2) + InstrumentedModules = @($ModuleNames) + RawClassCount = $classElementCount + RawLineCount = $rawLineElementCount + SourceFile = (Resolve-Path $InputCobertura).Path + GeneratedAt = (Get-Date).ToString('o') +} + +$summaryJsonPath = Join-Path $OutputDir 'corrected-summary.json' +$summaryCsvPath = Join-Path $OutputDir 'corrected-summary.csv' +$perFileCsvPath = Join-Path $OutputDir 'corrected-per-file.csv' +$correctedXmlPath = Join-Path $OutputDir 'corrected.cobertura.xml' + +$summary | ConvertTo-Json | Set-Content $summaryJsonPath +$summary | Export-Csv $summaryCsvPath -NoTypeInformation +$perFile | Sort-Object File | Export-Csv $perFileCsvPath -NoTypeInformation + +Write-Host "`nWriting corrected, deduplicated Cobertura XML (one per source file) -> $correctedXmlPath" +$xmlSettings = New-Object System.Xml.XmlWriterSettings +$xmlSettings.Indent = $false +$xmlSettings.Encoding = [System.Text.UTF8Encoding]::new($false) +$writer = [System.Xml.XmlWriter]::Create($correctedXmlPath, $xmlSettings) +try { + $writer.WriteStartDocument() + $writer.WriteStartElement('coverage') + $writer.WriteAttributeString('line-rate', $overallRate.ToString('G17', [System.Globalization.CultureInfo]::InvariantCulture)) + $writer.WriteAttributeString('branch-rate', '0') + $writer.WriteAttributeString('lines-covered', $overallCovered) + $writer.WriteAttributeString('lines-valid', $overallTotal) + $writer.WriteAttributeString('branches-covered', '0') + $writer.WriteAttributeString('branches-valid', '0') + $writer.WriteAttributeString('complexity', '0') + $writer.WriteAttributeString('version', '1.9') + $writer.WriteAttributeString('timestamp', [DateTimeOffset]::UtcNow.ToUnixTimeSeconds().ToString()) + + $writer.WriteStartElement('sources') + $writer.WriteElementString('source', '.') + $writer.WriteEndElement() # sources + + $writer.WriteStartElement('packages') + $writer.WriteStartElement('package') + $writer.WriteAttributeString('name', $moduleDisplayName) + $writer.WriteAttributeString('line-rate', $overallRate.ToString('G17', [System.Globalization.CultureInfo]::InvariantCulture)) + $writer.WriteAttributeString('branch-rate', '0') + $writer.WriteAttributeString('complexity', '0') + $writer.WriteStartElement('classes') + + foreach ($fn in $fileOrder) { + $dict = $fileLineMap[$fn] + $total = $dict.Count + $covered = 0 + foreach ($h in $dict.Values) { if ($h -gt 0) { $covered++ } } + $rate = if ($total -gt 0) { $covered / $total } else { 0 } + + $writer.WriteStartElement('class') + $writer.WriteAttributeString('name', $fn) + $writer.WriteAttributeString('filename', $fn) + $writer.WriteAttributeString('line-rate', $rate.ToString('G17', [System.Globalization.CultureInfo]::InvariantCulture)) + $writer.WriteAttributeString('branch-rate', '0') + $writer.WriteAttributeString('complexity', '0') + $writer.WriteStartElement('methods') + $writer.WriteEndElement() # methods + $writer.WriteStartElement('lines') + foreach ($num in ($dict.Keys | Sort-Object)) { + $writer.WriteStartElement('line') + $writer.WriteAttributeString('number', $num) + $writer.WriteAttributeString('hits', $dict[$num]) + $writer.WriteAttributeString('branch', 'false') + $writer.WriteEndElement() # line + } + $writer.WriteEndElement() # lines + $writer.WriteEndElement() # class + } + + $writer.WriteEndElement() # classes + $writer.WriteEndElement() # package + $writer.WriteEndElement() # packages + $writer.WriteEndElement() # coverage + $writer.WriteEndDocument() +} finally { + $writer.Close() +} + +Write-Host "`n=== Corrected coverage (bug-fixed, matches Azure DevOps's Code Coverage tab) ===" +Write-Host (" Instrumented DLL(s): {0}" -f $moduleDisplayName) +Write-Host (" Modules (files): {0}" -f $summary.Modules) +Write-Host (" Total lines: {0}" -f $summary.TotalLines) +Write-Host (" Covered lines: {0}" -f $summary.CoveredLines) +Write-Host (" Line coverage: {0}%" -f $summary.LineCoverage) +Write-Host "`nWrote:" +Write-Host " $summaryJsonPath" +Write-Host " $summaryCsvPath" +Write-Host " $perFileCsvPath" +Write-Host " $correctedXmlPath" diff --git a/.github/skills/vm-component-coverage/scripts/lib/Set-CoveragePipeAcl.ps1 b/.github/skills/vm-component-coverage/scripts/lib/Set-CoveragePipeAcl.ps1 new file mode 100644 index 000000000..616974f1c --- /dev/null +++ b/.github/skills/vm-component-coverage/scripts/lib/Set-CoveragePipeAcl.ps1 @@ -0,0 +1,84 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. +# +# Set-CoveragePipeAcl.ps1 - Put a NULL DACL on the code-coverage collector's named pipe. +# +# This sets the DACL in-process via P/Invoke, so the lab path never has to ship a native +# helper to the test agents. TAEF's te.processhost runs at +# low integrity (Centennial); by default it cannot connect to the collector's pipe and +# coverage comes back empty with ACCESS_DENIED. A NULL DACL opens the gate. +# +# Usage: +# .\Set-CoveragePipeAcl.ps1 -SessionId aabbccdd11223344 +# .\Set-CoveragePipeAcl.ps1 -PipeName '\\.\pipe\CodeCoverage.pipe.aabbccdd11223344' + +[CmdletBinding(DefaultParameterSetName = 'SessionId')] +param( + [Parameter(Mandatory = $true, ParameterSetName = 'SessionId')] + [string]$SessionId, + + [Parameter(Mandatory = $true, ParameterSetName = 'PipeName')] + [string]$PipeName +) + +$ErrorActionPreference = 'Stop' + +if ($PSCmdlet.ParameterSetName -eq 'SessionId') { + $PipeName = "\\.\pipe\CodeCoverage.pipe.$SessionId" +} + +# P/Invoke: open the pipe with WRITE_DAC and hand it a NULL DACL via SetSecurityInfo. +# (Add-Type compiles this with the in-box .NET Framework C# compiler on any Windows agent.) +if (-not ('WinUI.Coverage.PipeAcl' -as [type])) { + Add-Type -Namespace 'WinUI.Coverage' -Name 'PipeAcl' -MemberDefinition @' + [System.Runtime.InteropServices.DllImport("kernel32.dll", SetLastError = true, CharSet = System.Runtime.InteropServices.CharSet.Unicode)] + public static extern System.IntPtr CreateFileW( + string lpFileName, uint dwDesiredAccess, uint dwShareMode, System.IntPtr lpSecurityAttributes, + uint dwCreationDisposition, uint dwFlagsAndAttributes, System.IntPtr hTemplateFile); + + [System.Runtime.InteropServices.DllImport("advapi32.dll", SetLastError = false)] + public static extern uint SetSecurityInfo( + System.IntPtr handle, int ObjectType, uint SecurityInfo, + System.IntPtr psidOwner, System.IntPtr psidGroup, System.IntPtr pDacl, System.IntPtr pSacl); + + [System.Runtime.InteropServices.DllImport("kernel32.dll", SetLastError = true)] + public static extern bool CloseHandle(System.IntPtr hObject); + + // Open an existing pipe for WRITE_DAC | READ_CONTROL and set a NULL (allow-all) DACL. + public static uint SetNullDacl(string pipeName) + { + const uint WRITE_DAC = 0x00040000; + const uint READ_CONTROL = 0x00020000; + const uint OPEN_EXISTING = 3; + const int SE_KERNEL_OBJECT = 6; + const uint DACL_SECURITY_INFORMATION = 0x00000004; + + System.IntPtr h = CreateFileW(pipeName, WRITE_DAC | READ_CONTROL, 0, System.IntPtr.Zero, + OPEN_EXISTING, 0, System.IntPtr.Zero); + if (h == new System.IntPtr(-1)) + { + throw new System.ComponentModel.Win32Exception( + System.Runtime.InteropServices.Marshal.GetLastWin32Error(), + "Failed to open pipe " + pipeName); + } + try + { + // pDacl = NULL with DACL_SECURITY_INFORMATION set => NULL DACL (everyone allowed). + return SetSecurityInfo(h, SE_KERNEL_OBJECT, DACL_SECURITY_INFORMATION, + System.IntPtr.Zero, System.IntPtr.Zero, System.IntPtr.Zero, System.IntPtr.Zero); + } + finally + { + CloseHandle(h); + } + } +'@ +} + +Write-Warning "Opening coverage pipe '$PipeName' with a NULL DACL - every local user can access it until the collector exits. Fine on an ephemeral CI agent; avoid running this on a shared machine." +Write-Host "Opening pipe: $PipeName" +$result = [WinUI.Coverage.PipeAcl]::SetNullDacl($PipeName) +if ($result -ne 0) { + throw "SetSecurityInfo failed on '$PipeName' with error $result" +} +Write-Host "Successfully set NULL DACL on pipe" diff --git a/.github/skills/vm-component-coverage/scripts/lib/Split-CoverageByModule.ps1 b/.github/skills/vm-component-coverage/scripts/lib/Split-CoverageByModule.ps1 new file mode 100644 index 000000000..66acfdefb --- /dev/null +++ b/.github/skills/vm-component-coverage/scripts/lib/Split-CoverageByModule.ps1 @@ -0,0 +1,255 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. +# +# Split-CoverageByModule.ps1 +# +# Companion to Repair-CoverageDuplicates.ps1. That script applies the duplicate-line dedup fix +# across the WHOLE merged Cobertura file, producing a single aggregate number for every +# instrumented module combined. When a coverage run instruments MORE THAN ONE product DLL (e.g. +# Microsoft.ui.xaml.dll AND Microsoft.UI.Xaml.Controls.dll), the raw merge keeps them in separate +# elements - this script preserves that split, applying the exact same +# (file, line-number) dedup INDEPENDENTLY per package/module, so each DLL gets its own +# ADO-tab-accurate coverage number and per-file breakdown. +# +# Output (under -OutputDir): +# \corrected-summary.json / .csv +# \corrected-per-file.csv +# \corrected.cobertura.xml +# per-module-summary.csv - one row per module, side by side +# +# NOTE on totals: summing the per-module line totals will NOT necessarily equal the combined +# (all-modules) total from Repair-CoverageDuplicates.ps1, because a source file (typically a +# shared header) can legitimately be compiled into more than one module - the combined run +# dedups that file's lines once globally, whereas each module counts its own copy. Both numbers +# are correct for what they measure; use the per-module number when asking "how well is THIS DLL +# covered". + +[CmdletBinding()] +param( + # Path to the (buggy, duplicate-line) merged Cobertura XML produced by + # `dotnet-coverage merge ... -f cobertura` / Microsoft.CodeCoverage.Console.exe. + [Parameter(Mandatory = $true)] + [string]$InputCobertura, + + # Where to write the per-module folders and per-module-summary.csv. + [Parameter(Mandatory = $true)] + [string]$OutputDir, + + # Optional filter: only emit modules whose matches one of these (matching is + # case-insensitive and ignores a trailing ".dll", so "Microsoft.UI.Xaml.Controls.dll" matches + # a package named "Microsoft.ui.xaml.controls"). Omit to emit every module found. + [string[]]$Modules = @() +) + +$ErrorActionPreference = 'Stop' +Add-Type -AssemblyName System.Xml + +if (-not (Test-Path $InputCobertura)) { + throw "Input Cobertura file not found: $InputCobertura" +} +New-Item -ItemType Directory -Force -Path $OutputDir | Out-Null + +function Get-NormalizedModuleName { + param([string]$Name) + return ($Name -replace '\.dll$', '').ToLowerInvariant() +} + +$wanted = @() +foreach ($m in $Modules) { if ($m) { $wanted += (Get-NormalizedModuleName $m) } } + +Write-Host "Streaming and deduplicating (per module) $InputCobertura ..." +$sw = [System.Diagnostics.Stopwatch]::StartNew() + +# module -> (file -> (line -> hits)) +$moduleMap = New-Object 'System.Collections.Generic.Dictionary[string,object]' +$moduleOrder = New-Object 'System.Collections.Generic.List[string]' +# module -> ordered file list +$moduleFileOrder = New-Object 'System.Collections.Generic.Dictionary[string,object]' + +$reader = [System.Xml.XmlReader]::Create($InputCobertura) +$currentModule = $null +$currentFileMap = $null +$currentFileOrder = $null +$currentDict = $null +$classCount = 0 +$lineCount = 0 +try { + while ($reader.Read()) { + if ($reader.NodeType -ne [System.Xml.XmlNodeType]::Element) { continue } + + switch ($reader.Name) { + 'package' { + $currentModule = $reader.GetAttribute('name') + if (-not $currentModule) { $currentModule = '(unnamed)' } + if (-not $moduleMap.ContainsKey($currentModule)) { + $moduleMap[$currentModule] = New-Object 'System.Collections.Generic.Dictionary[string,System.Collections.Generic.Dictionary[int,int]]' + $moduleFileOrder[$currentModule] = New-Object 'System.Collections.Generic.List[string]' + $moduleOrder.Add($currentModule) + } + $currentFileMap = $moduleMap[$currentModule] + $currentFileOrder = $moduleFileOrder[$currentModule] + $currentDict = $null + } + 'class' { + $classCount++ + $fn = $reader.GetAttribute('filename') + if (-not $fn) { $fn = $reader.GetAttribute('name') } + if ($null -eq $currentFileMap) { continue } + if (-not $currentFileMap.TryGetValue($fn, [ref]$currentDict)) { + $currentDict = New-Object 'System.Collections.Generic.Dictionary[int,int]' + $currentFileMap[$fn] = $currentDict + $currentFileOrder.Add($fn) + } + } + 'line' { + if ($null -eq $currentDict) { continue } + $lineCount++ + $num = [int]$reader.GetAttribute('number') + $hits = [int]$reader.GetAttribute('hits') + $existing = 0 + if ($currentDict.TryGetValue($num, [ref]$existing)) { + if ($hits -gt $existing) { $currentDict[$num] = $hits } + } else { + $currentDict[$num] = $hits + } + } + } + } +} finally { + $reader.Close() +} + +Write-Host ("Parsed {0} module(s), {1} and {2} elements in {3:N1}s." -f $moduleOrder.Count, $classCount, $lineCount, $sw.Elapsed.TotalSeconds) +Write-Host ("Modules found: {0}" -f ($moduleOrder -join ', ')) + +$perModuleRows = New-Object System.Collections.Generic.List[object] + +foreach ($module in $moduleOrder) { + if ($wanted.Count -gt 0 -and -not ($wanted -contains (Get-NormalizedModuleName $module))) { + Write-Host "Skipping module '$module' (not in -Modules filter)." + continue + } + + $fileMap = $moduleMap[$module] + $fileOrder = $moduleFileOrder[$module] + $safeName = ($module -replace '[\\/:*?"<>|]', '_') + $moduleDir = Join-Path $OutputDir $safeName + New-Item -ItemType Directory -Force -Path $moduleDir | Out-Null + + $perFile = New-Object System.Collections.Generic.List[object] + $overallTotal = 0 + $overallCovered = 0 + foreach ($fn in $fileOrder) { + $dict = $fileMap[$fn] + $total = $dict.Count + $covered = 0 + foreach ($h in $dict.Values) { if ($h -gt 0) { $covered++ } } + $overallTotal += $total + $overallCovered += $covered + $perFile.Add([pscustomobject]@{ + File = $fn + CoveredLines = $covered + TotalLines = $total + LineCoverage = if ($total -gt 0) { [math]::Round(100.0 * $covered / $total, 2) } else { 0 } + }) + } + + $overallRate = if ($overallTotal -gt 0) { $overallCovered / $overallTotal } else { 0 } + $summary = [pscustomobject]@{ + Modules = $fileOrder.Count + TotalLines = $overallTotal + CoveredLines = $overallCovered + LineCoverage = [math]::Round(100.0 * $overallRate, 2) + InstrumentedModules = @($module) + SourceFile = (Resolve-Path $InputCobertura).Path + GeneratedAt = (Get-Date).ToString('o') + } + + $summary | ConvertTo-Json | Set-Content (Join-Path $moduleDir 'corrected-summary.json') + $summary | Export-Csv (Join-Path $moduleDir 'corrected-summary.csv') -NoTypeInformation + $perFile | Sort-Object File | Export-Csv (Join-Path $moduleDir 'corrected-per-file.csv') -NoTypeInformation + + $correctedXmlPath = Join-Path $moduleDir 'corrected.cobertura.xml' + $xmlSettings = New-Object System.Xml.XmlWriterSettings + $xmlSettings.Indent = $false + $xmlSettings.Encoding = [System.Text.UTF8Encoding]::new($false) + $writer = [System.Xml.XmlWriter]::Create($correctedXmlPath, $xmlSettings) + try { + $writer.WriteStartDocument() + $writer.WriteStartElement('coverage') + $writer.WriteAttributeString('line-rate', $overallRate.ToString('G17', [System.Globalization.CultureInfo]::InvariantCulture)) + $writer.WriteAttributeString('branch-rate', '0') + $writer.WriteAttributeString('lines-covered', $overallCovered) + $writer.WriteAttributeString('lines-valid', $overallTotal) + $writer.WriteAttributeString('branches-covered', '0') + $writer.WriteAttributeString('branches-valid', '0') + $writer.WriteAttributeString('complexity', '0') + $writer.WriteAttributeString('version', '1.9') + $writer.WriteAttributeString('timestamp', [DateTimeOffset]::UtcNow.ToUnixTimeSeconds().ToString()) + $writer.WriteStartElement('sources') + $writer.WriteElementString('source', '.') + $writer.WriteEndElement() + $writer.WriteStartElement('packages') + $writer.WriteStartElement('package') + $writer.WriteAttributeString('name', $module) + $writer.WriteAttributeString('line-rate', $overallRate.ToString('G17', [System.Globalization.CultureInfo]::InvariantCulture)) + $writer.WriteAttributeString('branch-rate', '0') + $writer.WriteAttributeString('complexity', '0') + $writer.WriteStartElement('classes') + foreach ($fn in $fileOrder) { + $dict = $fileMap[$fn] + $total = $dict.Count + $covered = 0 + foreach ($h in $dict.Values) { if ($h -gt 0) { $covered++ } } + $rate = if ($total -gt 0) { $covered / $total } else { 0 } + $writer.WriteStartElement('class') + $writer.WriteAttributeString('name', $fn) + $writer.WriteAttributeString('filename', $fn) + $writer.WriteAttributeString('line-rate', $rate.ToString('G17', [System.Globalization.CultureInfo]::InvariantCulture)) + $writer.WriteAttributeString('branch-rate', '0') + $writer.WriteAttributeString('complexity', '0') + $writer.WriteStartElement('methods') + $writer.WriteEndElement() + $writer.WriteStartElement('lines') + foreach ($num in ($dict.Keys | Sort-Object)) { + $writer.WriteStartElement('line') + $writer.WriteAttributeString('number', $num) + $writer.WriteAttributeString('hits', $dict[$num]) + $writer.WriteAttributeString('branch', 'false') + $writer.WriteEndElement() + } + $writer.WriteEndElement() # lines + $writer.WriteEndElement() # class + } + $writer.WriteEndElement() # classes + $writer.WriteEndElement() # package + $writer.WriteEndElement() # packages + $writer.WriteEndElement() # coverage + $writer.WriteEndDocument() + } finally { + $writer.Close() + } + + $perModuleRows.Add([pscustomobject]@{ + Module = $module + Files = $fileOrder.Count + TotalLines = $overallTotal + CoveredLines = $overallCovered + LineCoverage = $summary.LineCoverage + OutputFolder = $moduleDir + }) + + Write-Host ("`n=== {0} ===" -f $module) + Write-Host (" Files: {0}" -f $fileOrder.Count) + Write-Host (" Total lines: {0}" -f $overallTotal) + Write-Host (" Covered lines: {0}" -f $overallCovered) + Write-Host (" Line coverage: {0}%" -f $summary.LineCoverage) +} + +if ($perModuleRows.Count -eq 0) { + throw "No modules were emitted. Modules present in the input were: $($moduleOrder -join ', '). Check the -Modules filter." +} + +$perModuleCsv = Join-Path $OutputDir 'per-module-summary.csv' +$perModuleRows | Export-Csv $perModuleCsv -NoTypeInformation +Write-Host "`nWrote per-module summary: $perModuleCsv" From 086912ffd4e97673bf0b86d78e690bfe65a7caf8 Mon Sep 17 00:00:00 2001 From: Satwik Kumar Sharma Date: Tue, 13 Oct 2026 14:44:58 +0530 Subject: [PATCH 02/10] Address review feedback on vm-component-coverage skill - Make the skill self-contained: remove references to internal repos, PRs, builds and other skills; describe the mechanics inline instead. - Orchestrator: stop the transcript even when Steps 1-3 fail. - Step 2: reuse the instrumented-DLL cache only while the instrumenting tool still exists at the same version; refresh payload backups whenever a copy is not instrumented so a rebuilt payload is never restored to an older DLL. - Step 3: on failure, still download the collector log and always close the PowerShell Direct session. - Step 4: fail on a VM hash mismatch and keep instrument-state.json for retry. - Step 5: make paths repo-relative using -RepoRoot (or a build-agent root) so checkouts under folders like C:\src still join with a baseline; list functions for any component's hand-written .cpp files, not only direct children of controls\dev\. - SKILL.md: map cached credentials via Get-VM names; include build-generated .g.cpp/.g.h files in the component regex rule. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: fcbd0fa8-6888-4e0c-96aa-0c60b83530e6 --- .github/skills/vm-component-coverage/SKILL.md | 84 ++++++++++++------- .../scripts/Invoke-WinUIVmCoverage.ps1 | 81 +++++++++--------- .../scripts/Step1-New-InstrumentableDll.ps1 | 11 ++- .../scripts/Step2-Instrument-Payload.ps1 | 43 ++++++---- .../scripts/Step3-Invoke-VmCoverageRun.ps1 | 21 +++-- .../scripts/Step4-Restore-Payload.ps1 | 15 +++- .../scripts/Step5-Get-ComponentCoverage.ps1 | 23 +++-- .../scripts/lib/Build-CoverageTreeReport.ps1 | 6 +- .../lib/Build-FunctionCoverageTreeReport.ps1 | 2 +- .../scripts/lib/Get-FunctionCoverage.ps1 | 25 +++--- .../scripts/lib/Repair-CoverageDuplicates.ps1 | 6 +- .../scripts/lib/Set-CoveragePipeAcl.ps1 | 4 +- 12 files changed, 187 insertions(+), 134 deletions(-) diff --git a/.github/skills/vm-component-coverage/SKILL.md b/.github/skills/vm-component-coverage/SKILL.md index 9702a81e6..43254c9a6 100644 --- a/.github/skills/vm-component-coverage/SKILL.md +++ b/.github/skills/vm-component-coverage/SKILL.md @@ -18,10 +18,10 @@ All commands run from the repo root. `` = `.github\skills\vm-component-co | Check | How | If missing | |---|---|---| -| Local build of the flavor (default `amd64chk`) | `BuildOutput\bin\amd64chk\Product\Microsoft.UI.Xaml.Controls.dll` and `BuildOutput\obj\amd64chk\controls\dev\dll\**\link.command.1.tlog` exist | Follow the **build** skill. Ask before starting a full build (10+ min). | +| Local build of the flavor (default `amd64chk`) | `BuildOutput\bin\amd64chk\Product\Microsoft.UI.Xaml.Controls.dll` and `BuildOutput\obj\amd64chk\controls\dev\dll\**\link.command.1.tlog` exist | Ask before building (10+ min), then run `.\initrun.ps1 .\build.cmd /q` from the repo root. | | VS Enterprise code coverage | `Microsoft.CodeCoverage.Console.exe` under `%ProgramFiles%\Microsoft Visual Studio` | Stop; the user must install it. | | `dotnet-coverage` | `Get-Command dotnet-coverage` | Tell the user: `dotnet tool install --global dotnet-coverage` | -| Hyper-V rights | `Get-VM` succeeds | Follow the permission steps in the **test-on-vm** skill. Never elevate or add group members yourself. | +| Hyper-V rights | `Get-VM` succeeds | Stop. Ask the user to add their account (from `whoami`) to **Hyper-V Administrators** in an elevated PowerShell window, then sign out and back in. Never elevate or add group members yourself. | ### Gate 1 - VM details (strict: never start a run without them) @@ -29,14 +29,20 @@ All commands run from the repo root. `` = `.github\skills\vm-component-co session.** Never pick a VM silently, even if only one credential is cached. Never reuse a VM name, username or password from memory, an earlier session, or an earlier run without asking again. -1. Look for cached credentials. This shows the VM file and username only, never the password: +1. Look for cached credentials. Match them against the real VM names, because the cache file name + replaces non-alphanumeric characters with `_` and can't be turned back into the VM name. This + shows the username only, never the password: ```powershell - Get-ChildItem "$env:USERPROFILE\.winui-test\vmcred-*.xml" -EA SilentlyContinue | - ForEach-Object { [pscustomobject]@{ VM = $_.BaseName -replace '^vmcred-'; UserName = (Import-Clixml $_.FullName).UserName } } + Get-VM | ForEach-Object { + $f = Join-Path $env:USERPROFILE ".winui-test\vmcred-$($_.Name -replace '[^a-zA-Z0-9]','_').xml" + if (Test-Path $f) { [pscustomobject]@{ VM = $_.Name; State = $_.State; UserName = (Import-Clixml $f).UserName; CacheFile = Split-Path $f -Leaf } } + } ``` + If two VMs share one `CacheFile` (for example `Test-VM` and `Test_VM`), don't offer the cache for + either; ask for details and re-enter the password (`-Force`). 2. Use `ask_user` every time: - - **Cached credentials exist:** ask "Is this the VM to use?" Offer one choice per cached VM and - username, plus **"Enter different VM details"**. + - **Cached credentials exist:** ask "Is this the VM to use?" Offer one choice per VM and + username listed above, plus **"Enter different VM details"**. - **No cache, or the user chooses different details:** ask for the **VM name** and **VM username** (the plain account name as shown on the VM, with no `.\` or `VM\` prefix). - If the user declines or cancels, stop. Do not continue with a default. @@ -66,8 +72,11 @@ username or password from memory, an earlier session, or an earlier run without - If no single wildcard covers them all, pass several filters (`-TestFilter 'a','b'`). Each runs with its own collector, and the results are merged and deduplicated in the report. - If the folder has no tests, tell the user and ask which tests to run. -3. `-ComponentPathRegex`: `\\controls\\dev\\\\` plus `\\controls\\dev\\Generated\\(||...)\.` - for each `runtimeclass` in `controls\dev\\*.idl` that has a generated file in `controls\dev\Generated`. +3. `-ComponentPathRegex`: take every `runtimeclass` `` declared in `controls\dev\\*.idl` and match + the folder plus both kinds of generated sources: + `\\controls\\dev\\\\|\\controls\\dev\\Generated\\(||...)\.|\\dll\\generated\\(.+\\)?(||...)\.g\.` + The second part covers `controls\dev\Generated\.properties.*` and the third covers the build-generated + `BuildOutput\obj\\controls\dev\dll\generated\...\.g.cpp/.h` files. 4. Show the resolved plan (folder, test classes, filter(s), regex), then proceed. Ask only if something is ambiguous. @@ -106,25 +115,34 @@ Run from the repo root in PowerShell, invoking the script directly. Don't use `p ### Report - Confirm Step 4 restored the payload. If the log shows `RESTORE FAILED`, tell the user prominently. + `instrument-state.json` is kept in that case, so offer to rerun + `\scripts\Step4-Restore-Payload.ps1 -RepoRoot -WorkDir \work -VMName `. - From `report-\coverage-summary.md`, present the component total, the per-file table, and the uncovered and partially covered functions. Point to `TreeReport\coverage-tree.html` and the CSVs. - Check the test summary. If `Total=0`, the filter matched nothing; fix the filter instead of reporting 0 %. --- -# Reference (original skill documentation) +# Reference Measures **which lines of one component your selected tests execute**. It doesn't need a pipeline run: it uses your local build and the same Hyper-V VM and runner (`tools\run-tests-on-vm.ps1`) you -already use for WinUI tests. +already use for WinUI tests. Everything it needs is in this folder. -It's built from three references: +## How it works -| Reference | What was reused | -|---|---| -| `microsoft-ui-xaml-lift` PR **16435603** ("Add focused code coverage pipeline") | How to make MUXC instrumentable (`/debugtype:cv,fixup` + `/INCREMENTAL:NO`, commit `2919de62`). Static instrumentation with a per-module session id, broadcast to every payload copy plus `static_covrun*.dll`. A server-mode collector with a NULL-DACL pipe (`Set-CoveragePipeAcl.ps1`, copied unmodified into `scripts\lib`). Shutdown to flush. | -| `WinUITestInventory\SkillToGetCodeCoverageFromPipeline` | The (file, line) dedup fix that makes numbers match the ADO Code Coverage tab (`Split-CoverageByModule.ps1`), the HTML tree report, and function-level coverage. These scripts are copied **unmodified** into `scripts\lib`. | -| `WinUITestInventory\CoverageReportFromScripts` | The same merge flow (`dotnet-coverage merge` to Cobertura), and the note that the PDB is only needed at instrument time. | +- **Instrumentable MUXC:** static native instrumentation needs CV and FIXUP debug info + (`/DEBUGTYPE:CV,FIXUP` with `/INCREMENTAL:NO`), which a chk MUXC link doesn't emit. Step 1 relinks a + copy with those flags from the recorded link command, without touching the repo or `BuildOutput`. +- **Collection:** the DLL is instrumented once with a per-module session id and copied over every + payload copy, together with `static_covrun*.dll`. On the VM, a server-mode collector listens on a named pipe + that `lib\Set-CoveragePipeAcl.ps1` opens to low-integrity test processes. Shutting the collector down + flushes the `.coverage` file. +- **Accurate numbers:** merging native `.coverage` files to Cobertura repeats every line once per input + file, so naive totals undercount coverage. `lib\Repair-CoverageDuplicates.ps1` and + `lib\Split-CoverageByModule.ps1` keep one entry per (file, line), which matches the Azure DevOps Code + Coverage tab. `lib\Get-FunctionCoverage.ps1` adds per-function detail from the native XML merge, and the + `Build-*TreeReport.ps1` scripts render single-file HTML reports. The PDB is only needed at instrument time. ## Quick start @@ -138,8 +156,8 @@ cd \.github\skills\vm-component-coverage\scripts -ComponentPathRegex '\\controls\\dev\\TitleBar\\|\\Generated\\TitleBar|\\generated\\Controls\\TitleBar' ` -OutputDir \CodeCoverageOutput\TitleBar ` -MoveSessionToConsole ` - -BaselinePerFileCsv '\PerModule\Microsoft.UI.Xaml.Controls\corrected-per-file.csv' ` - -BaselineLabel 'CI build 157731159 (all tests)' + -BaselinePerFileCsv '\PerModule\Microsoft.UI.Xaml.Controls\corrected-per-file.csv' ` + -BaselineLabel 'Full test pass' ``` Open `\report-\coverage-summary.md`. Other outputs in that folder: @@ -163,11 +181,11 @@ Open `\report-\coverage-summary.md`. Other outputs in that | Step | Script | Notes | |---|---|---| -| 1 | `Step1-New-InstrumentableDll.ps1` | **MUXC only.** chk MUXC is not instrumentable: the tool prints "*Unable to obtain debug information. Link with the /PROFILE linker switch.*" and then **exits 0** with the image untouched. The lift PR fixes this with an opt-in `EnableCodeCoverageLinkOptions` MSBuild property, which this checkout may not have, and repo files must not be edited. So the script re-runs the **exact link command MSBuild recorded** (`link.command.1.tlog`) with `/DEBUGTYPE:CV,FIXUP` added and `/INCREMENTAL:NO`. `/OUT`, `/PDB`, `/IMPLIB` and `/MAP` are redirected to `work\relink`, and `/WINMD:NO` is set. It takes about 15 s, and BuildOutput is never touched. MUX (`Microsoft.ui.xaml.dll`) already links with `/debugtype:cv,fixup,pdata` and skips this step. | -| 2 | `Step2-Instrument-Payload.ps1` | Static native instrumentation with a fresh 16-hex session id. **This is slow for a 38 MB chk DLL (about 20 min)**, so the result is cached in `work\instrumented`, keyed by the input DLL's SHA256, and reused on later runs together with its baked-in session id. The script catches the "exit 0 but not instrumented" case. It backs up and then overwrites **every** payload copy of the DLL, plus `static_covrun64.dll`, because each test app loads its own co-located copy (`Test\UnpackagedApps\MUXControlsTestApp\`). | -| 3 | `Step3-Invoke-VmCoverageRun.ps1` | Stages the collector on the VM once. It starts `collect --server-mode --session-id ` as the VM user through an interactive scheduled task, waits for `\\.\pipe\CodeCoverage.pipe.`, and sets a NULL DACL on it. It then runs `initrun.ps1 .\tools\run-tests-on-vm.ps1 -SkipPayload `, which deploys the instrumented payload and runs the tests on the desktop. In `finally` it calls `shutdown ` so the collector writes the `.coverage` file, then downloads it. | -| 4 | `Step4-Restore-Payload.ps1` | Always runs, from `finally`. It restores every original DLL, hash-verified, removes `static_covrun*.dll`, and does the same in the VM's deployed payload. Each distinct original is restored to its own copies. | -| 5 | `Step5-Get-ComponentCoverage.ps1` | Merges the coverage to Cobertura (`dotnet-coverage merge -f cobertura`) and applies the per-module (file, line) dedup (`lib\Split-CoverageByModule.ps1`), the same correction that matches the ADO tab. It then filters to the component by `-ComponentPathRegex` and optionally compares with a baseline `corrected-per-file.csv`. Paths are matched repo-relative, so `C:\__w\1\s\...` versus `C:\Repos\...` and `x86chk` versus `amd64chk` don't matter. It also produces function-level coverage (`lib\Get-FunctionCoverage.ps1`) and the HTML tree (`lib\Build-CoverageTreeReport.ps1`). | +| 1 | `Step1-New-InstrumentableDll.ps1` | **MUXC only.** chk MUXC is not instrumentable: the tool prints "*Unable to obtain debug information. Link with the /PROFILE linker switch.*" and then **exits 0** with the image untouched. It needs `/DEBUGTYPE:CV,FIXUP` with `/INCREMENTAL:NO` at link time, and this skill never edits repo files, so the script re-runs the **exact link command MSBuild recorded** (`link.command.1.tlog`) with `/DEBUGTYPE:CV,FIXUP` added and `/INCREMENTAL:NO`. `/OUT`, `/PDB`, `/IMPLIB` and `/MAP` are redirected to `work\relink`, and `/WINMD:NO` is set. It takes about 15 s, and BuildOutput is never touched. MUX (`Microsoft.ui.xaml.dll`) already links with `/debugtype:cv,fixup,pdata` and skips this step. | +| 2 | `Step2-Instrument-Payload.ps1` | Static native instrumentation with a fresh 16-hex session id. **This is slow for a 38 MB chk DLL (about 20 min)**, so the result is cached in `work\instrumented`, keyed by the input DLL's SHA256, and reused on later runs together with its baked-in session id. The cache is reused only while the tool that instrumented it still exists at the same version. The script catches the "exit 0 but not instrumented" case. It backs up and then overwrites **every** payload copy of the DLL, plus `static_covrun64.dll`, because each test app loads its own co-located copy (`Test\UnpackagedApps\MUXControlsTestApp\`). Backups are refreshed from the payload whenever a copy isn't instrumented, so a rebuilt payload is never restored to an older DLL. | +| 3 | `Step3-Invoke-VmCoverageRun.ps1` | Stages the collector on the VM once. It starts `collect --server-mode --session-id ` as the VM user through an interactive scheduled task, waits for `\\.\pipe\CodeCoverage.pipe.`, and sets a NULL DACL on it. It then runs `initrun.ps1 .\tools\run-tests-on-vm.ps1 -SkipPayload `, which deploys the instrumented payload and runs the tests on the desktop. In `finally` it calls `shutdown ` so the collector writes the `.coverage` file, downloads it and the collector log (also when the run failed), and closes the VM session. | +| 4 | `Step4-Restore-Payload.ps1` | Always runs, from `finally`. It restores every original DLL, hash-verified, removes `static_covrun*.dll`, and does the same in the VM's deployed payload. Each distinct original is restored to its own copies. If any VM copy doesn't match afterwards, it fails and keeps `instrument-state.json`, so you can simply run it again. | +| 5 | `Step5-Get-ComponentCoverage.ps1` | Merges the coverage to Cobertura (`dotnet-coverage merge -f cobertura`) and applies the per-module (file, line) dedup (`lib\Split-CoverageByModule.ps1`), the same correction that matches the ADO tab. It then filters to the component by `-ComponentPathRegex` and optionally compares with a baseline `corrected-per-file.csv`. Paths are matched repo-relative: the checkout root (`-RepoRoot`) or a build-agent root such as `C:\__w\1\s\` is removed, and `x86chk` versus `amd64chk` doesn't matter. It also produces function-level coverage (`lib\Get-FunctionCoverage.ps1`) and the HTML tree (`lib\Build-CoverageTreeReport.ps1`). | You can run the steps one at a time. Each writes its state or results under `\work`. @@ -179,9 +197,9 @@ You can run the steps one at a time. Each writes its state or results under `\collector.log` and confirm the app-local copy (`MUXControlsTestApp\Microsoft.UI.Xaml.Controls.dll`) is the instrumented one. - **Rebuilds overwrite the instrumented copy:** rebuilding `MUXControlsTestApp` re-stages an older Controls DLL into `BuildOutput\...\UnpackagedApps\MUXControlsTestApp`. That doesn't matter here because Step 2 overwrites the *payload* copies, but don't run `run-tests-on-vm.ps1` **without** `-SkipPayload` in between, because that recreates the payload. - **Expected failures still count toward coverage:** failing or ignored tests still execute product code until they fail, and Ignored tests don't run at all. -- **Known crash at exit (PC-3) is harmless:** TitleBar runs fail-fast at app exit, which shows as cleanup errors. Coverage still flushes because the collector is out of process. +- **Known crash at exit is harmless:** TitleBar runs fail-fast at app exit, which shows as cleanup errors. Coverage still flushes because the collector is out of process. - **Numbers differ from CI:** a local VM run uses a different OS build, flavor (x64 vs CI's x86) and test subset. Compare per file, not the module total. - **NULL DACL:** the collector pipe is open to every local user until shutdown. That's fine on a test VM, but avoid it on shared machines. - **`cmd /c` quoting:** starting the collector via `cmd /c "" ... > ""` silently fails, because cmd strips the outer quotes. Step 3 therefore writes a `.cmd` wrapper. If the collector pipe never appears, check that wrapper and `C:\WinUICoverage\out\collector-.log` on the VM. -- **Same tool for instrument and collect:** the collector must be the same `Microsoft.CodeCoverage.Console.exe` version that instrumented the DLL. Step 2 records the tool, and Step 3 stages that exact version to `C:\WinUICoverage\CoverageTool\`. When several VS installs exist, the highest file version wins; path string order is not used. +- **Same tool for instrument and collect:** the collector must be the same `Microsoft.CodeCoverage.Console.exe` version that instrumented the DLL. Step 2 records the tool, and Step 3 stages that exact version to `C:\WinUICoverage\CoverageTool\`. If that tool is gone or changed version (for example after a VS update), Step 2 re-instruments instead of reusing the cache. When several VS installs exist, the highest file version wins; path string order is not used. - **Relink is not byte-deterministic:** Step 1 therefore reuses its previous output while the recorded link inputs are unchanged. Otherwise the Step 2 cache, keyed by DLL hash, would miss and re-instrument for 45 minutes. -- **Single-file bug in `lib\Get-FunctionCoverage.ps1`:** with a single `.coverage` input, the script's own merge produces an empty ``. An if-expression unwraps the one-element array, so `@inputFiles` splats nothing. Step 5 works around this by pre-merging (`dotnet-coverage merge -f xml`) and calling the script with `-SkipMerge`. The copied script is left unmodified. +- **Single-file bug in `lib\Get-FunctionCoverage.ps1`:** with a single `.coverage` input, the script's own merge produces an empty ``. An if-expression unwraps the one-element array, so `@inputFiles` splats nothing. Step 5 works around this by pre-merging (`dotnet-coverage merge -f xml`) and calling the script with `-SkipMerge`, so the script itself is left as is. - **Host suspends pause the run:** a Cloud PC host that sleeps or suspends pauses instrumentation; it resumes and finishes afterwards. Keep the host awake for predictable timing. ## Parameters (`Invoke-WinUIVmCoverage.ps1`) @@ -217,7 +235,7 @@ Of 45 functions in the TitleBar `.cpp` files, 40 are fully covered, 4 partially, | `-ComponentName` / `-ComponentPathRegex` | TitleBar | Regex on the source path selecting the component's files | | `-Module` | `Microsoft.UI.Xaml.Controls.dll` | or `Microsoft.ui.xaml.dll` | | `-Flavor` | `amd64chk` | Build flavor; payload `x64chk` | -| `-BaselinePerFileCsv` / `-BaselineLabel` | none | e.g. a CI `corrected-per-file.csv` from the pipeline skill | +| `-BaselinePerFileCsv` / `-BaselineLabel` | none | A `corrected-per-file.csv` from another coverage run to compare with, e.g. `PerModule\\corrected-per-file.csv` from an earlier run of this skill, or `lib\Split-CoverageByModule.ps1` run over a CI build's merged Cobertura file | | `-MoveSessionToConsole` | off | Recommended on Cloud PC / RDP hosts | | `-RefreshPayload` | off | Run `test\CreateTestPayload.ps1` first | | `-ForceReinstrument` | off | Ignore the instrumented-DLL cache | @@ -234,6 +252,8 @@ Of 45 functions in the TitleBar `.cpp` files, 40 are fully covered, 4 partially, | `scripts\Step4-Restore-Payload.ps1` | Restores the payload and the VM copies, hash-verified. | | `scripts\Set-VmCredential.ps1` | Gate 1 helper: validates the VM, takes the password in a separate window, writes the encrypted credential cache, and checks the logon and the active desktop. | | `scripts\Step5-Get-ComponentCoverage.ps1` | Merge, per-module dedup, component filter, baseline comparison, function coverage, HTML tree and `coverage-summary.md`. | -| `scripts\lib\Set-CoveragePipeAcl.ps1` | Unmodified copy from lift PR 16435603 (`Helix\common\pipeline\coverage\`). | -| `scripts\lib\Split-CoverageByModule.ps1`, `Repair-CoverageDuplicates.ps1`, `Build-CoverageTreeReport.ps1`, `Get-FunctionCoverage.ps1`, `Build-FunctionCoverageTreeReport.ps1` | Unmodified copies from `WinUITestInventory\SkillToGetCodeCoverageFromPipeline\scripts`. | +| `scripts\lib\Set-CoveragePipeAcl.ps1` | Puts a NULL DACL on the collector's named pipe so low-integrity TAEF test processes can connect. | +| `scripts\lib\Repair-CoverageDuplicates.ps1`, `Split-CoverageByModule.ps1` | Deduplicate merged Cobertura by (file, line), overall and per module, so the numbers match the Azure DevOps Code Coverage tab. | +| `scripts\lib\Get-FunctionCoverage.ps1` | Per-function coverage from the native XML merge. | +| `scripts\lib\Build-CoverageTreeReport.ps1`, `Build-FunctionCoverageTreeReport.ps1` | Single-file HTML folder/file (and function) tree reports. | diff --git a/.github/skills/vm-component-coverage/scripts/Invoke-WinUIVmCoverage.ps1 b/.github/skills/vm-component-coverage/scripts/Invoke-WinUIVmCoverage.ps1 index 090525940..9207c51e3 100644 --- a/.github/skills/vm-component-coverage/scripts/Invoke-WinUIVmCoverage.ps1 +++ b/.github/skills/vm-component-coverage/scripts/Invoke-WinUIVmCoverage.ps1 @@ -5,16 +5,16 @@ .DESCRIPTION relink (instrumentable copy) -> instrument + broadcast into TestPayload -> collector + tests on VM - -> download .coverage -> restore payload/VM -> component report (optionally vs. a CI baseline). + -> download .coverage -> restore payload/VM -> component report (optionally vs. a baseline). The payload and the VM are restored in a finally block even if the run fails. .EXAMPLE - # TitleBar coverage from the TitleBar tests only, compared to CI build 157731159's numbers - .\Invoke-WinUIVmCoverage.ps1 -RepoRoot C:\Repos\WinUiGitHubRepo\microsoft-ui-xaml -VMName WinUiTestVM1 ` + # TitleBar coverage from the TitleBar tests only, compared to a baseline from another coverage run + .\Invoke-WinUIVmCoverage.ps1 -RepoRoot C:\src\microsoft-ui-xaml -VMName MyTestVM ` -TestFilter '*.TitleBarTests.*' -ComponentName TitleBar ` -ComponentPathRegex '\\controls\\dev\\TitleBar\\|\\Generated\\TitleBar|\\generated\\Controls\\TitleBar' ` - -OutputDir C:\cov\TitleBar -MoveSessionToConsole ` - -BaselinePerFileCsv C:\...\CodeCoverageOutput_157731159\PerModule\Microsoft.UI.Xaml.Controls\corrected-per-file.csv + -OutputDir C:\src\microsoft-ui-xaml\CodeCoverageOutput\TitleBar -MoveSessionToConsole ` + -BaselinePerFileCsv C:\cov\baseline\PerModule\Microsoft.UI.Xaml.Controls\corrected-per-file.csv #> [CmdletBinding()] param( @@ -46,47 +46,48 @@ $payloadFlavor = "$plat$cfg" $runLabel = Get-Date -Format 'yyyyMMdd-HHmmss' $transcript = Join-Path $OutputDir "coverage-run-$runLabel.log" Start-Transcript $transcript | Out-Null +# Outer try/finally: the transcript is stopped even when Steps 1-3 fail (Step 4 still runs first). try { - $sw = [Diagnostics.Stopwatch]::StartNew() - if ($RefreshPayload) { - Write-Host "== Refreshing TestPayload\$payloadFlavor from BuildOutput" - Push-Location $RepoRoot - try { & .\initrun.ps1 -Flavor $Flavor .\test\CreateTestPayload.ps1 -Platform $plat -Configuration $cfg -Mode DevTestSuite -SkipSymbols -Quiet } - finally { Pop-Location } - } + try { + $sw = [Diagnostics.Stopwatch]::StartNew() + if ($RefreshPayload) { + Write-Host "== Refreshing TestPayload\$payloadFlavor from BuildOutput" + Push-Location $RepoRoot + try { & .\initrun.ps1 -Flavor $Flavor .\test\CreateTestPayload.ps1 -Platform $plat -Configuration $cfg -Mode DevTestSuite -SkipSymbols -Quiet } + finally { Pop-Location } + } - Write-Host "== Step 1: instrumentable $Module" - if ($Module -eq 'Microsoft.UI.Xaml.Controls.dll') { - $rl = & (Join-Path $scripts 'Step1-New-InstrumentableDll.ps1') -RepoRoot $RepoRoot -OutputDir (Join-Path $work 'relink') -Flavor $Flavor - $srcDll = $rl.dll - } else { - # MUX already links with /debugtype:cv,fixup,pdata; stage its DLL+PDB side by side. - $d = Join-Path $work 'mux'; New-Item -ItemType Directory -Force $d | Out-Null - Copy-Item (Join-Path $RepoRoot "BuildOutput\bin\$Flavor\Product\Microsoft.ui.xaml.dll") $d -Force - Copy-Item (Get-ChildItem (Join-Path $RepoRoot "BuildOutput\bin\$Flavor") -Recurse -Filter Microsoft.ui.xaml.pdb | Select-Object -First 1).FullName $d -Force - $srcDll = Join-Path $d 'Microsoft.ui.xaml.dll' - } + Write-Host "== Step 1: instrumentable $Module" + if ($Module -eq 'Microsoft.UI.Xaml.Controls.dll') { + $rl = & (Join-Path $scripts 'Step1-New-InstrumentableDll.ps1') -RepoRoot $RepoRoot -OutputDir (Join-Path $work 'relink') -Flavor $Flavor + $srcDll = $rl.dll + } else { + # MUX already links with /debugtype:cv,fixup,pdata; stage its DLL+PDB side by side. + $d = Join-Path $work 'mux'; New-Item -ItemType Directory -Force $d | Out-Null + Copy-Item (Join-Path $RepoRoot "BuildOutput\bin\$Flavor\Product\Microsoft.ui.xaml.dll") $d -Force + Copy-Item (Get-ChildItem (Join-Path $RepoRoot "BuildOutput\bin\$Flavor") -Recurse -Filter Microsoft.ui.xaml.pdb | Select-Object -First 1).FullName $d -Force + $srcDll = Join-Path $d 'Microsoft.ui.xaml.dll' + } - Write-Host "== Step 2: instrument + broadcast into payload" - $st = & (Join-Path $scripts 'Step2-Instrument-Payload.ps1') -RepoRoot $RepoRoot -WorkDir $work -SourceDll $srcDll -PayloadFlavor $payloadFlavor -ForceReinstrument:$ForceReinstrument + Write-Host "== Step 2: instrument + broadcast into payload" + $st = & (Join-Path $scripts 'Step2-Instrument-Payload.ps1') -RepoRoot $RepoRoot -WorkDir $work -SourceDll $srcDll -PayloadFlavor $payloadFlavor -ForceReinstrument:$ForceReinstrument - Write-Host "== Step 3: collector + tests on $VMName" - $runs = @() - for ($i = 0; $i -lt $TestFilter.Count; $i++) { - $label = if ($TestFilter.Count -gt 1) { "$runLabel-$($i + 1)" } else { $runLabel } - Write-Host " filter $($i + 1)/$($TestFilter.Count): $($TestFilter[$i])" - $runs += & (Join-Path $scripts 'Step3-Invoke-VmCoverageRun.ps1') -RepoRoot $RepoRoot -WorkDir $work -VMName $VMName -TestFilter $TestFilter[$i] ` - -Flavor $Flavor -Credential $Credential -RunLabel $label -MoveSessionToConsole:$MoveSessionToConsole + Write-Host "== Step 3: collector + tests on $VMName" + $runs = @() + for ($i = 0; $i -lt $TestFilter.Count; $i++) { + $label = if ($TestFilter.Count -gt 1) { "$runLabel-$($i + 1)" } else { $runLabel } + Write-Host " filter $($i + 1)/$($TestFilter.Count): $($TestFilter[$i])" + $runs += & (Join-Path $scripts 'Step3-Invoke-VmCoverageRun.ps1') -RepoRoot $RepoRoot -WorkDir $work -VMName $VMName -TestFilter $TestFilter[$i] ` + -Flavor $Flavor -Credential $Credential -RunLabel $label -MoveSessionToConsole:$MoveSessionToConsole + } + } + finally { + Write-Host "== Step 4: restore payload + VM" + try { & (Join-Path $scripts 'Step4-Restore-Payload.ps1') -RepoRoot $RepoRoot -WorkDir $work -VMName $VMName -Credential $Credential } + catch { Write-Warning "RESTORE FAILED - fix manually: $_" } } -} -finally { - Write-Host "== Step 4: restore payload + VM" - try { & (Join-Path $scripts 'Step4-Restore-Payload.ps1') -RepoRoot $RepoRoot -WorkDir $work -VMName $VMName -Credential $Credential } - catch { Write-Warning "RESTORE FAILED - fix manually: $_" } -} -try { Write-Host "== Step 5: $ComponentName report" - $rep = & (Join-Path $scripts 'Step5-Get-ComponentCoverage.ps1') -CoverageFiles @($runs.coverageFile) -OutputDir (Join-Path $OutputDir "report-$runLabel") ` + $rep = & (Join-Path $scripts 'Step5-Get-ComponentCoverage.ps1') -CoverageFiles @($runs.coverageFile) -OutputDir (Join-Path $OutputDir "report-$runLabel") -RepoRoot $RepoRoot ` -Module ([IO.Path]::GetFileNameWithoutExtension($Module)) -ComponentPathRegex $ComponentPathRegex -ComponentName $ComponentName ` -BaselinePerFileCsv $BaselinePerFileCsv -BaselineLabel $BaselineLabel -RunLabel "tests '$($TestFilter -join "', '")'" -SkipFunctionCoverage:$SkipFunctionCoverage $testSummary = ($runs | ForEach-Object testSummary) -join ' | ' diff --git a/.github/skills/vm-component-coverage/scripts/Step1-New-InstrumentableDll.ps1 b/.github/skills/vm-component-coverage/scripts/Step1-New-InstrumentableDll.ps1 index f5c0f58bd..30af27d87 100644 --- a/.github/skills/vm-component-coverage/scripts/Step1-New-InstrumentableDll.ps1 +++ b/.github/skills/vm-component-coverage/scripts/Step1-New-InstrumentableDll.ps1 @@ -7,12 +7,11 @@ Static native instrumentation (Microsoft.CodeCoverage.Console instrument) needs the CV + FIXUP debug streams. MUXC's Debug (chk) link does not emit them, so instrumenting the normal chk DLL prints "Unable to obtain debug information. Link with the /PROFILE linker switch." and exits 0 - with the image untouched (see microsoft-ui-xaml-lift PR 16435603, commit 2919de62: - "Make MUXC instrumentable for code coverage by emitting CV/FIXUP debug info", which adds - /debugtype:cv,fixup + /INCREMENTAL:NO behind an opt-in EnableCodeCoverageLinkOptions property). + with the image untouched. Emitting them needs /DEBUGTYPE:CV,FIXUP with /INCREMENTAL:NO at link + time. - That property does not exist in every checkout, and this skill must not edit repo files, so this - script re-runs the EXACT link command MSBuild last recorded (link.command.1.tlog) with: + This skill must not edit repo files, so instead of changing the build this script re-runs the + EXACT link command MSBuild last recorded (link.command.1.tlog) with: * /DEBUGTYPE:CV,FIXUP added (and /INCREMENTAL:NO enforced), * /OUT, /PDB, /IMPLIB, /MAP redirected into -OutputDir, * /WINMD disabled (the .winmd is not needed for coverage). @@ -23,7 +22,7 @@ this step and instrument BuildOutput\bin\\Product\Microsoft.ui.xaml.dll directly. .EXAMPLE - .\Step1-New-InstrumentableDll.ps1 -RepoRoot C:\Repos\WinUiGitHubRepo\microsoft-ui-xaml -OutputDir C:\cov\work\relink + .\Step1-New-InstrumentableDll.ps1 -RepoRoot C:\src\microsoft-ui-xaml -OutputDir C:\cov\work\relink #> [CmdletBinding()] param( diff --git a/.github/skills/vm-component-coverage/scripts/Step2-Instrument-Payload.ps1 b/.github/skills/vm-component-coverage/scripts/Step2-Instrument-Payload.ps1 index 8ee47d598..9b317f539 100644 --- a/.github/skills/vm-component-coverage/scripts/Step2-Instrument-Payload.ps1 +++ b/.github/skills/vm-component-coverage/scripts/Step2-Instrument-Payload.ps1 @@ -3,18 +3,22 @@ Step 2 - Statically instrument the module and broadcast it into the local test payload. .DESCRIPTION - Mirrors Helix\common\pipeline\coverage\Instrument-CoveragePayload.ps1 from microsoft-ui-xaml-lift - PR 16435603, adapted for a local payload that is deployed to a Hyper-V VM: + Static native instrumentation of one product DLL for a local payload that is deployed to a + Hyper-V VM: * Instruments ONE copy (VS Microsoft.CodeCoverage.Console.exe, static native instrumentation, a fresh 16-hex session id) - this is slow for a 38 MB chk DLL (~10-20 min), so the result is CACHED in \instrumented keyed by the SHA256 of the input DLL and reused next time - (together with the session id that is baked into it). + (together with the session id that is baked into it). The cache is only reused while the + coverage tool that instrumented it still exists at the same file version, because the + collector (Step 3) must be that same tool. * Detects "fails while exiting 0" (no /PROFILE-style debug info) by checking the tool output and that the image grew, and requires static_covrun*.dll to be produced. - * Backs up every payload copy of the module to \payload-backup (once), then copies - the instrumented DLL + static_covrun*.dll over every copy (a test app loads its own - co-located copy, e.g. Test\UnpackagedApps\MUXControlsTestApp\). + * Backs up every payload copy of the module to \payload-backup, then copies the + instrumented DLL + static_covrun*.dll over every copy (a test app loads its own co-located + copy, e.g. Test\UnpackagedApps\MUXControlsTestApp\). The backup is refreshed from the payload + whenever that copy is not instrumented, so a rebuilt payload is never restored to an older + build's DLL; an existing backup is kept only while the copy is still instrumented. * Writes \instrument-state.json (session id, copies, backups, runtimes added) used by the run and restore steps. @@ -57,13 +61,17 @@ $srcHash = (Get-FileHash $SourceDll).Hash # ---- 1. instrument (or reuse cache) --------------------------------------------------------- $cache = if (Test-Path $cacheFile) { Get-Content $cacheFile -Raw | ConvertFrom-Json } else { $null } $instrDll = Join-Path $instrDir $moduleName -if (-not $ForceReinstrument -and $cache -and $cache.sourceHash -eq $srcHash -and (Test-Path $instrDll) -and - (Get-FileHash $instrDll).Hash -eq $cache.instrumentedHash) { +$cacheHit = -not $ForceReinstrument -and $cache -and $cache.sourceHash -eq $srcHash -and (Test-Path $instrDll) -and + (Get-FileHash $instrDll).Hash -eq $cache.instrumentedHash +# The collector must come from the same tool (and version) that instrumented the binary. +$cacheToolOk = $cache -and $cache.coverageTool -and (Test-Path $cache.coverageTool) -and + "$((Get-Item $cache.coverageTool).VersionInfo.FileVersion)" -eq "$($cache.toolVersion)" +if ($cacheHit -and $cacheToolOk) { $sessionId = $cache.sessionId - # The collector must come from the same tool that instrumented the binary. - if ($cache.coverageTool -and (Test-Path $cache.coverageTool)) { $CoverageConsole = $cache.coverageTool; $toolVersion = $cache.toolVersion } + $CoverageConsole = $cache.coverageTool; $toolVersion = $cache.toolVersion Info "reusing cached instrumented DLL (session $sessionId, tool $toolVersion)" } else { + if ($cacheHit) { Info "cached DLL was instrumented by '$($cache.coverageTool)' $($cache.toolVersion), which is no longer available at that version; re-instrumenting" } Remove-Item $instrDir -Recurse -Force -EA SilentlyContinue New-Item -ItemType Directory -Force $instrDir | Out-Null Copy-Item $SourceDll, $pdb $instrDir @@ -100,7 +108,7 @@ if (-not $ForceReinstrument -and $cache -and $cache.sourceHash -eq $srcHash -and } $runtimes = @(Get-ChildItem $instrDir -Filter 'static_covrun*.dll') -# ---- 2. broadcast into the payload (backup originals once) ---------------------------------- +# ---- 2. broadcast into the payload (back up originals) -------------------------------------- $payload = Join-Path $RepoRoot "TestPayload\$PayloadFlavor" if (-not (Test-Path (Join-Path $payload 'runtests.cmd'))) { throw "Test payload not found at $payload. Run tools\run-tests-on-vm.ps1 (or test\CreateTestPayload.ps1) once first." } $backupRoot = Join-Path $WorkDir 'payload-backup' @@ -111,15 +119,22 @@ if ($state -and $state.payloadDir -ne $payload) { throw "instrument-state.json b $copies = @(Get-ChildItem $payload -Recurse -Filter $moduleName -File) if (-not $copies) { throw "No copies of $moduleName under $payload" } $instrHash = (Get-FileHash $instrDll).Hash +$knownInstrumented = @($instrHash, $(if ($state) { $state.instrumentedHash })) | Where-Object { $_ } $entries = @() $runtimesAdded = @() foreach ($c in $copies) { $rel = $c.FullName.Substring($payload.Length + 1) $bak = Join-Path $backupRoot $rel - if (-not (Test-Path $bak)) { - if ((Get-FileHash $c.FullName).Hash -eq $instrHash) { throw "$rel is already instrumented but has no backup; recreate the payload." } + # A copy that is not instrumented is the payload's current original (possibly from a newer build + # than an earlier backup), so always back it up. Keep an existing backup only while the copy is + # still instrumented (e.g. an earlier run was not restored yet). + $isInstrumented = ($knownInstrumented -contains (Get-FileHash $c.FullName).Hash) -or + [bool](Get-ChildItem $c.DirectoryName -Filter 'static_covrun*.dll' -File -EA SilentlyContinue) + if (-not $isInstrumented) { New-Item -ItemType Directory -Force (Split-Path $bak) | Out-Null - Copy-Item $c.FullName $bak + Copy-Item $c.FullName $bak -Force + } elseif (-not (Test-Path $bak)) { + throw "$rel is already instrumented but has no backup; recreate the payload." } Copy-Item $instrDll $c.FullName -Force foreach ($rt in $runtimes) { diff --git a/.github/skills/vm-component-coverage/scripts/Step3-Invoke-VmCoverageRun.ps1 b/.github/skills/vm-component-coverage/scripts/Step3-Invoke-VmCoverageRun.ps1 index a9e3114fb..0a5d5ee8b 100644 --- a/.github/skills/vm-component-coverage/scripts/Step3-Invoke-VmCoverageRun.ps1 +++ b/.github/skills/vm-component-coverage/scripts/Step3-Invoke-VmCoverageRun.ps1 @@ -4,8 +4,7 @@ download the resulting .coverage file. .DESCRIPTION - Collector mechanics follow microsoft-ui-xaml-lift PR 16435603 - (Helix\common\pipeline\RunTestPassSliceOnBuildAgent.ps1 -CollectCodeCoverage): + How the collector is wired up: 1. Stage the VS code-coverage tools (CodeCoverage.Console + Dynamic Code Coverage Tools + InstrumentationEngine) on the VM once (C:\WinUICoverage\CoverageTool), no VS/dotnet needed. 2. Start "Microsoft.CodeCoverage.Console.exe collect --session-id --server-mode --output " @@ -15,7 +14,8 @@ 4. Run the tests with the repo's own tools\run-tests-on-vm.ps1 -SkipPayload (deploys the instrumented payload incrementally and runs runtests.cmd on the interactive desktop). 5. "Microsoft.CodeCoverage.Console.exe shutdown " -> collector finalizes the .coverage. - 6. Copy the .coverage + collector logs back to \raw. + 6. Copy the .coverage + collector logs back to \raw (also when the run fails), and + always close the PowerShell Direct session. Precondition: the VM user's desktop must be interactive. If the host is a Cloud PC / RDP session that can disconnect, use -MoveSessionToConsole so input does not depend on vmconnect's enhanced @@ -176,14 +176,17 @@ finally { } -ArgumentList $vmTool, $sid, $vmOut, $FlushTimeoutSec Info ("collector shutdown: {0}; coverage size {1:N0} bytes" -f $flush.shutdown, $flush.size) } catch { Write-Warning "collector shutdown failed: $_" } + + # ---- 6. download (also on failure, so the collector log is kept) and always close the session - + # Copy errors are not raised here (they would hide the original failure); a missing or empty + # coverage file is reported below. + $local = Join-Path $rawDir (Split-Path $vmOut -Leaf) + try { + Copy-Item $vmOut $local -FromSession $session -Force -EA SilentlyContinue + Copy-Item $vmLog (Join-Path $rawDir 'collector.log') -FromSession $session -Force -EA SilentlyContinue + } finally { Remove-PSSession $session -EA SilentlyContinue } } -# ---- 6. download ------------------------------------------------------------------------------- -try { - $local = Join-Path $rawDir (Split-Path $vmOut -Leaf) - Copy-Item $vmOut $local -FromSession $session -Force - Copy-Item $vmLog (Join-Path $rawDir 'collector.log') -FromSession $session -Force -EA SilentlyContinue -} finally { Remove-PSSession $session } if (-not (Test-Path $local) -or (Get-Item $local).Length -lt 1024) { throw "Coverage file missing or empty ($local). Check $rawDir\collector.log and that the test process loaded the instrumented DLL (session $sid)." } diff --git a/.github/skills/vm-component-coverage/scripts/Step4-Restore-Payload.ps1 b/.github/skills/vm-component-coverage/scripts/Step4-Restore-Payload.ps1 index 40c010e29..1e66e6237 100644 --- a/.github/skills/vm-component-coverage/scripts/Step4-Restore-Payload.ps1 +++ b/.github/skills/vm-component-coverage/scripts/Step4-Restore-Payload.ps1 @@ -7,8 +7,10 @@ * copies every backed-up original back over its payload copy (hash-verified), * deletes the static_covrun*.dll files that Step 2 added, * optionally (-VMName) does the same inside the VM's deployed payload - (C:\TestPayload\-, the path tools\run-tests-on-vm.ps1 uses), - * removes instrument-state.json (backups are kept for audit). + (C:\TestPayload\-, the path tools\run-tests-on-vm.ps1 uses) and + throws if any VM copy does not match its original afterwards, + * removes instrument-state.json only after everything was restored (on failure it is kept, + so this script can simply be run again; backups are kept for audit). The deploy manifest of tools\run-tests-on-vm.ps1 is size+mtime based, so the next normal run re-deploys the restored files anyway; the VM step just makes the VM clean immediately. #> @@ -45,6 +47,7 @@ if ($VMName) { $remoteDir = "C:\TestPayload\$env:COMPUTERNAME-$(Split-Path $RepoRoot -Leaf)" $s = New-PSSession -VMName $VMName -Credential $Credential try { + $vmErrors = @() Invoke-Command -Session $s -ScriptBlock { New-Item -ItemType Directory -Force C:\WinUICoverage | Out-Null } # Copies can come from different originals (e.g. an app folder with an older staged DLL), # so restore each distinct original to exactly the copies it was backed up from. @@ -58,11 +61,17 @@ if ($VMName) { Remove-Item $tmp -Force $bad = @($rels | Where-Object { (Test-Path (Join-Path $dir $_)) -and (Get-FileHash (Join-Path $dir $_)).Hash -ne $hash }) if ($bad) { "WARNING: hash mismatch on VM for $($bad -join ', ')" } else { "VM restored $($rels.Count) copy(ies) of original $($hash.Substring(0,8))" } - } -ArgumentList $remoteDir, $tmp, @($g.Group.relativePath), $g.Name | ForEach-Object { Info $_ } + } -ArgumentList $remoteDir, $tmp, @($g.Group.relativePath), $g.Name | ForEach-Object { + Info $_ + if ("$_" -like 'WARNING:*') { $vmErrors += "$_" } + } } Invoke-Command -Session $s -ScriptBlock { param($dir, [string[]]$rts) foreach ($r in $rts) { Remove-Item (Join-Path $dir $r) -Force -EA SilentlyContinue } } -ArgumentList $remoteDir, @($state.runtimesAdded) + if ($vmErrors) { + throw "VM restore incomplete ($($vmErrors -join '; ')). instrument-state.json was kept; run Step4-Restore-Payload.ps1 again." + } } finally { Remove-PSSession $s } } Remove-Item $statePath -Force diff --git a/.github/skills/vm-component-coverage/scripts/Step5-Get-ComponentCoverage.ps1 b/.github/skills/vm-component-coverage/scripts/Step5-Get-ComponentCoverage.ps1 index fe9b1b573..019464a5c 100644 --- a/.github/skills/vm-component-coverage/scripts/Step5-Get-ComponentCoverage.ps1 +++ b/.github/skills/vm-component-coverage/scripts/Step5-Get-ComponentCoverage.ps1 @@ -3,8 +3,7 @@ Step 5 - Turn the downloaded .coverage file(s) into a component-scoped coverage report. .DESCRIPTION - Reuses the (unmodified) scripts from the SkillToGetCodeCoverageFromPipeline skill, copied into - .\lib: + Uses the scripts in .\lib: * dotnet-coverage merge -f cobertura -> merged.cobertura.xml * lib\Split-CoverageByModule.ps1 -> per-module, (file,line)-deduplicated numbers (same correction that makes numbers match ADO) @@ -13,8 +12,8 @@ and adds: * component filtering (-ComponentPathRegex, matched against the source path), * an optional before/after comparison against a baseline corrected-per-file.csv (e.g. from a - CI coverage build), matched on the repo-relative path so build-agent prefixes - (C:\__w\1\s\...) and flavors (x86chk/amd64chk) do not matter, + CI coverage build), matched on the repo-relative path so checkout roots, build-agent + prefixes (C:\__w\1\s\...) and flavors (x86chk/amd64chk) do not matter, * coverage-summary.md + component-per-file.csv + component-functions.csv. #> [CmdletBinding()] @@ -28,6 +27,8 @@ param( [string]$BaselinePerFileCsv = '', [string]$BaselineLabel = 'Baseline', [string]$RunLabel = 'This run', + # Root of the checkout that produced the coverage; used to make its paths repo-relative. + [string]$RepoRoot = '', [switch]$SkipFunctionCoverage ) $ErrorActionPreference = 'Stop' @@ -53,10 +54,15 @@ if (-not $modDir) { throw "Module $Module not found in coverage (modules: $((Get $modCsv = Join-Path $modDir.FullName 'corrected-per-file.csv' $modSummary = Get-Content (Join-Path $modDir.FullName 'corrected-summary.json') -Raw | ConvertFrom-Json +$rootKey = if ($RepoRoot) { ($RepoRoot.ToLowerInvariant() -replace '/', '\').TrimEnd('\') + '\' } else { '' } function Get-RepoKey([string]$p) { - # repo-relative key: strip everything up to the repo root, and normalize build flavor dirs + # repo-relative key: strip the checkout root (this run), else a build-agent root (e.g. a CI + # baseline: C:\__w\1\s\), else everything up to the first top-level repo folder; then + # normalize build flavor dirs. $k = $p.ToLowerInvariant() -replace '/', '\' - $k = $k -replace '^.*?\\(?=(controls|dxaml|buildoutput|src|test)\\)', '' + if ($rootKey -and $k.StartsWith($rootKey)) { $k = $k.Substring($rootKey.Length) } + elseif ($k -match '^[a-z]:\\+(__w|a)\\+\d+\\+s\\+') { $k = $k.Substring($Matches[0].Length) } + else { $k = $k -replace '^.*?\\(?=(controls|dxaml|buildoutput|src|test)\\)', '' } $k -replace '\\obj\\(x86|amd64|arm64)(chk|fre)\\', '\obj\\' } @@ -85,7 +91,7 @@ $tot = ($comp | Measure-Object TotalLines -Sum).Sum $cov = ($comp | Measure-Object CoveredLines -Sum).Sum $pct = if ($tot) { [math]::Round(100.0 * $cov / $tot, 2) } else { 0 } -# HTML tree for the component (reuses the pipeline skill renderer; it needs File,CoveredLines,TotalLines) +# HTML tree for the component (lib\Build-CoverageTreeReport.ps1; it needs File,CoveredLines,TotalLines) $treeCsv = Join-Path $OutputDir 'component-tree-input.csv' $comp | Select-Object @{n='File';e={$_.SourcePath}}, CoveredLines, TotalLines, LineCoverage | Export-Csv $treeCsv -NoTypeInformation & (Join-Path $lib 'Build-CoverageTreeReport.ps1') -InputCsv $treeCsv -OutputDir (Join-Path $OutputDir 'TreeReport') ` @@ -132,7 +138,8 @@ foreach ($c in $comp) { [void]$md.AppendLine("| ``$($c.File)`` | $($c.CoveredLines) / $($c.TotalLines) | **$($c.LineCoverage) %** | $bp |") } if ($funcRows) { - $hand = $funcRows | Where-Object { $_.File -match '\\controls\\dev\\' + [regex]::Escape($ComponentName) + '\\[^\\]+\.cpp$' } + # Hand-written .cpp files of the component (works for controls\dev and dxaml components alike). + $hand = $funcRows | Where-Object { $_.File -match '\.cpp$' -and $_.File -notmatch '\\generated\\|\\winrtgeneratedclasses\\|\.g\.cpp$' } $unc = @($hand | Where-Object { [int]$_.CoveredLines -eq 0 }) $part = @($hand | Where-Object { [int]$_.CoveredLines -gt 0 -and [int]$_.CoveredLines -lt [int]$_.TotalLines }) [void]$md.AppendLine() diff --git a/.github/skills/vm-component-coverage/scripts/lib/Build-CoverageTreeReport.ps1 b/.github/skills/vm-component-coverage/scripts/lib/Build-CoverageTreeReport.ps1 index af116f49f..a193f8643 100644 --- a/.github/skills/vm-component-coverage/scripts/lib/Build-CoverageTreeReport.ps1 +++ b/.github/skills/vm-component-coverage/scripts/lib/Build-CoverageTreeReport.ps1 @@ -41,7 +41,7 @@ param( [string]$Title = "Code Coverage Report", # Regex matched against the START of each file path and stripped before display (the - # "boilerplate" build-agent prefix). Default matches the OneBranch convention + # "boilerplate" build-agent prefix). Default matches the Azure Pipelines agent convention # (e.g. C:\__w\1\s\, D:\a\3\s\ etc. - a drive letter, "__w" or "a", a numeric leg id, then # "s\") case-insensitively, on either slash direction. If a given path doesn't match, it is # left completely unchanged - this is a display-only trim, never a hard requirement. @@ -194,8 +194,8 @@ if (-not $summary) { } # Resolve which DLL(s) this report is measuring: an explicit -ModuleNames wins; otherwise fall -# back to the "InstrumentedModules" field already recorded in corrected-summary.json (as passed -# through from Get-PipelineCodeCoverage.ps1's -Module parameter); if neither is available the +# back to the "InstrumentedModules" field already recorded in corrected-summary.json (when the +# producer recorded one); if neither is available the # report simply omits the module line rather than guessing at a DLL name. $resolvedModuleNames = @($ModuleNames) if ($resolvedModuleNames.Count -eq 0 -and $summary.PSObject.Properties.Name -contains 'InstrumentedModules') { diff --git a/.github/skills/vm-component-coverage/scripts/lib/Build-FunctionCoverageTreeReport.ps1 b/.github/skills/vm-component-coverage/scripts/lib/Build-FunctionCoverageTreeReport.ps1 index be6c35ac3..9b6b756ff 100644 --- a/.github/skills/vm-component-coverage/scripts/lib/Build-FunctionCoverageTreeReport.ps1 +++ b/.github/skills/vm-component-coverage/scripts/lib/Build-FunctionCoverageTreeReport.ps1 @@ -37,7 +37,7 @@ [CmdletBinding()] param( # Path to corrected-per-file.csv, as produced by Repair-CoverageDuplicates.ps1 / - # Get-PipelineCodeCoverage.ps1. Drives the folder/file levels of the tree (authoritative + # Split-CoverageByModule.ps1. Drives the folder/file levels of the tree (authoritative # totals - matches the Azure DevOps Code Coverage tab). [Parameter(Mandatory = $true)] [string]$InputPerFileCsv, diff --git a/.github/skills/vm-component-coverage/scripts/lib/Get-FunctionCoverage.ps1 b/.github/skills/vm-component-coverage/scripts/lib/Get-FunctionCoverage.ps1 index d01d38654..5c3aab5da 100644 --- a/.github/skills/vm-component-coverage/scripts/lib/Get-FunctionCoverage.ps1 +++ b/.github/skills/vm-component-coverage/scripts/lib/Get-FunctionCoverage.ps1 @@ -40,7 +40,7 @@ # physical lines - each instantiation is legitimately "using" the same textual lines as its # siblings. Because of this, DO NOT use this script's per-function rows to re-derive a file's # true aggregate line coverage - use the existing, Azure-DevOps-verified `corrected-per-file.csv` -# (from Repair-CoverageDuplicates.ps1 / Get-PipelineCodeCoverage.ps1) for that; pass it via +# (from Repair-CoverageDuplicates.ps1 / Split-CoverageByModule.ps1) for that; pass it via # -ExistingPerFileCsv to have this script merge those authoritative file totals alongside the new # per-function/function-count breakdown in corrected-per-file-function-summary.csv. # @@ -60,19 +60,19 @@ # authoritative FileTotalLines/FileCoveredLines/ # FileLineCoveragePct joined in from corrected-per-file.csv. # -# EXAMPLE (reusing a prior Get-PipelineCodeCoverage.ps1 run's already-downloaded slices, joining -# in the authoritative per-file totals it already computed) -# .\Get-FunctionCoverage.ps1 -SliceDir C:\Coverage\154383932\SliceCoverage ` -# -OutputDir C:\Coverage\154383932\FunctionCoverage ` -# -ExistingPerFileCsv C:\Coverage\154383932\CorrectedReport\corrected-per-file.csv +# EXAMPLE (all .coverage files under a folder, joining in the authoritative per-file totals that +# Split-CoverageByModule.ps1 already computed for them) +# .\Get-FunctionCoverage.ps1 -SliceDir C:\cov\run1\raw ` +# -OutputDir C:\cov\run1\FunctionCoverage ` +# -ExistingPerFileCsv C:\cov\run1\PerModule\Microsoft.UI.Xaml.Controls\corrected-per-file.csv # # EXAMPLE (explicit list of .coverage files) # .\Get-FunctionCoverage.ps1 -CoverageFiles file1.coverage,file2.coverage -OutputDir .\out [CmdletBinding(DefaultParameterSetName = 'FromSliceDir')] param( - # Directory to search recursively for raw *.coverage files (e.g. the SliceCoverage folder - # that Get-PipelineCodeCoverage.ps1 populates). All matches are merged together. + # Directory to search recursively for raw *.coverage files (e.g. a folder holding one + # .coverage file per test run or slice). All matches are merged together. [Parameter(Mandatory = $true, ParameterSetName = 'FromSliceDir')] [string]$SliceDir, @@ -102,13 +102,12 @@ param( [bool]$TreatPartialAsCovered = $true, # Keep NativeMerge\merged-native.xml after the run instead of leaving it (it is NOT deleted - # by default - unlike Get-PipelineCodeCoverage.ps1's small per-slice temp zips, this file is - # the expensive-to-regenerate one, so it is kept unless this switch is explicitly reversed by - # passing -KeepIntermediateXml:$false). + # by default - this file is the expensive-to-regenerate one, so it is kept unless this switch + # is explicitly reversed by passing -KeepIntermediateXml:$false). [bool]$KeepIntermediateXml = $true, # Optional path to an existing corrected-per-file.csv (as produced by - # Repair-CoverageDuplicates.ps1 / Get-PipelineCodeCoverage.ps1). When supplied, that file's + # Repair-CoverageDuplicates.ps1 / Split-CoverageByModule.ps1). When supplied, that file's # already Azure-DevOps-verified per-file TotalLines/CoveredLines/LineCoverage are joined into # corrected-per-file-function-summary.csv as FileTotalLines/FileCoveredLines/ # FileLineCoveragePct, alongside this script's own function-count columns. Recommended - @@ -125,7 +124,7 @@ New-Item -ItemType Directory -Force -Path $OutputDir, $nativeMergeDir | Out-Null $nativeXmlPath = Join-Path $nativeMergeDir 'merged-native.xml' # --------------------------------------------------------------------------------------------- -# 1. Resolve a coverage merge tool (same resolution order as Get-PipelineCodeCoverage.ps1). +# 1. Resolve a coverage merge tool (dotnet-coverage on PATH, else VS's console tool, else install). # --------------------------------------------------------------------------------------------- function Resolve-CoverageTool { param([string]$Explicit) diff --git a/.github/skills/vm-component-coverage/scripts/lib/Repair-CoverageDuplicates.ps1 b/.github/skills/vm-component-coverage/scripts/lib/Repair-CoverageDuplicates.ps1 index 5a4a30140..9268a7e66 100644 --- a/.github/skills/vm-component-coverage/scripts/lib/Repair-CoverageDuplicates.ps1 +++ b/.github/skills/vm-component-coverage/scripts/lib/Repair-CoverageDuplicates.ps1 @@ -10,7 +10,8 @@ # THE BUG: when ` merge -f cobertura` merges multiple raw .coverage # files that all instrument the SAME native module (e.g. one module instrumented once, then # exercised by N separate collector processes on N separate machines - exactly what happens when -# a test pass is split into parallel Helix/lab slices with one collector per slice), the merge +# a test pass is split into parallel slices with one collector per slice, or when one module is +# collected by several separate test runs), the merge # does NOT consolidate matching (file, line-number) entries into one. Instead it appends a # fresh copy of that class's elements per contributing input file. This was verified to # be 100% systemic (every single element in a real merged report exhibited it) and to @@ -35,8 +36,7 @@ # publish task, etc.) will read back with the SAME correct numbers. # # This script is self-contained and works on ANY Cobertura XML produced by dotnet-coverage / -# Microsoft.CodeCoverage.Console's merge - not just ones produced by the sibling -# Get-PipelineCodeCoverage.ps1 script in this skill. +# Microsoft.CodeCoverage.Console's merge. [CmdletBinding()] param( diff --git a/.github/skills/vm-component-coverage/scripts/lib/Set-CoveragePipeAcl.ps1 b/.github/skills/vm-component-coverage/scripts/lib/Set-CoveragePipeAcl.ps1 index 616974f1c..1224d7b41 100644 --- a/.github/skills/vm-component-coverage/scripts/lib/Set-CoveragePipeAcl.ps1 +++ b/.github/skills/vm-component-coverage/scripts/lib/Set-CoveragePipeAcl.ps1 @@ -3,8 +3,8 @@ # # Set-CoveragePipeAcl.ps1 - Put a NULL DACL on the code-coverage collector's named pipe. # -# This sets the DACL in-process via P/Invoke, so the lab path never has to ship a native -# helper to the test agents. TAEF's te.processhost runs at +# This sets the DACL in-process via P/Invoke, so no native helper has to be shipped to the +# test machine. TAEF's te.processhost runs at # low integrity (Centennial); by default it cannot connect to the collector's pipe and # coverage comes back empty with ACCESS_DENIED. A NULL DACL opens the gate. # From bc5b4a01f0af0e4f00cc858c1667e6b9e8165cb6 Mon Sep 17 00:00:00 2001 From: Satwik Kumar Sharma Date: Tue, 13 Oct 2026 17:56:37 +0530 Subject: [PATCH 03/10] Fix stale VM deploy and fail fast when no tests match Found by an end-to-end TitleBar run on a Hyper-V VM. - Step 3: drop the instrumented DLL copies and static_covrun*.dll from run-tests-on-vm.ps1's incremental deploy manifest before each run. Step 4 restores the VM without updating that manifest and static_covrun*.dll keep the tool's timestamp, so later runs either tested the original DLL (empty coverage) or deployed the instrumented DLL without its runtime (test app crashed with "ClassFactory cannot supply requested class"). - Step 3: report "No tests matched ''" instead of a null-reference error when TAEF selects no tests. - Orchestrator: before instrumenting, list the matching tests in the payload with TAEF (same selection as runtests on the VM) and stop if a filter matches none, e.g. when BuildOutput predates the component's tests. -SkipTestCheck bypasses it. - SKILL.md: document the check, the deploy manifest and the stale-build case; correct instrumentation time to 20-50 min. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: fcbd0fa8-6888-4e0c-96aa-0c60b83530e6 --- .github/skills/vm-component-coverage/SKILL.md | 15 +++++++--- .../scripts/Invoke-WinUIVmCoverage.ps1 | 28 +++++++++++++++++++ .../scripts/Step3-Invoke-VmCoverageRun.ps1 | 21 ++++++++++++++ 3 files changed, 60 insertions(+), 4 deletions(-) diff --git a/.github/skills/vm-component-coverage/SKILL.md b/.github/skills/vm-component-coverage/SKILL.md index 43254c9a6..a1fa3d8f0 100644 --- a/.github/skills/vm-component-coverage/SKILL.md +++ b/.github/skills/vm-component-coverage/SKILL.md @@ -79,6 +79,10 @@ username or password from memory, an earlier session, or an earlier run without `BuildOutput\obj\\controls\dev\dll\generated\...\.g.cpp/.h` files. 4. Show the resolved plan (folder, test classes, filter(s), regex), then proceed. Ask only if something is ambiguous. +5. The build must contain those tests. Before instrumenting, the orchestrator lists the matching tests in + `TestPayload\` with TAEF (the same selection `runtests` uses on the VM) and stops in seconds if a + filter matches none. That usually means `BuildOutput` was built from an older commit or another branch. + Tell the user, ask before rebuilding (`.\initrun.ps1 .\build.cmd /q`), then rerun with `-RefreshPayload`. **B. Not in `controls\dev`: investigate `dxaml` (MUX, `Microsoft.ui.xaml.dll`) and get approval** @@ -99,7 +103,7 @@ username or password from memory, an earlier session, or an earlier run without - Output: `\CodeCoverageOutput\` (already ignored by `.gitignore`). - Add `-RefreshPayload` if `TestPayload\x64chk` is missing or older than the build output. - Add `-MoveSessionToConsole` when the host is an RDP or Cloud PC session (`$env:SESSIONNAME -like 'RDP-*'`). -- Tell the user the expected time first (about 30-45 min the first time for MUXC, about 6 min with the cache). +- Tell the user the expected time first (about 30-60 min the first time for MUXC, mostly instrumentation; about 6 min with the cache). Run it async with a long wait. **Never stop the process during Step 2 or 3**; the restore runs in `finally`. Run from the repo root in PowerShell, invoking the script directly. Don't use `pwsh -File`: it turns @@ -182,7 +186,7 @@ Open `\report-\coverage-summary.md`. Other outputs in that | Step | Script | Notes | |---|---|---| | 1 | `Step1-New-InstrumentableDll.ps1` | **MUXC only.** chk MUXC is not instrumentable: the tool prints "*Unable to obtain debug information. Link with the /PROFILE linker switch.*" and then **exits 0** with the image untouched. It needs `/DEBUGTYPE:CV,FIXUP` with `/INCREMENTAL:NO` at link time, and this skill never edits repo files, so the script re-runs the **exact link command MSBuild recorded** (`link.command.1.tlog`) with `/DEBUGTYPE:CV,FIXUP` added and `/INCREMENTAL:NO`. `/OUT`, `/PDB`, `/IMPLIB` and `/MAP` are redirected to `work\relink`, and `/WINMD:NO` is set. It takes about 15 s, and BuildOutput is never touched. MUX (`Microsoft.ui.xaml.dll`) already links with `/debugtype:cv,fixup,pdata` and skips this step. | -| 2 | `Step2-Instrument-Payload.ps1` | Static native instrumentation with a fresh 16-hex session id. **This is slow for a 38 MB chk DLL (about 20 min)**, so the result is cached in `work\instrumented`, keyed by the input DLL's SHA256, and reused on later runs together with its baked-in session id. The cache is reused only while the tool that instrumented it still exists at the same version. The script catches the "exit 0 but not instrumented" case. It backs up and then overwrites **every** payload copy of the DLL, plus `static_covrun64.dll`, because each test app loads its own co-located copy (`Test\UnpackagedApps\MUXControlsTestApp\`). Backups are refreshed from the payload whenever a copy isn't instrumented, so a rebuilt payload is never restored to an older DLL. | +| 2 | `Step2-Instrument-Payload.ps1` | Static native instrumentation with a fresh 16-hex session id. **This is slow for a 38 MB chk DLL (about 20-50 min)**, so the result is cached in `work\instrumented`, keyed by the input DLL's SHA256, and reused on later runs together with its baked-in session id. The cache is reused only while the tool that instrumented it still exists at the same version. The script catches the "exit 0 but not instrumented" case. It backs up and then overwrites **every** payload copy of the DLL, plus `static_covrun64.dll`, because each test app loads its own co-located copy (`Test\UnpackagedApps\MUXControlsTestApp\`). Backups are refreshed from the payload whenever a copy isn't instrumented, so a rebuilt payload is never restored to an older DLL. | | 3 | `Step3-Invoke-VmCoverageRun.ps1` | Stages the collector on the VM once. It starts `collect --server-mode --session-id ` as the VM user through an interactive scheduled task, waits for `\\.\pipe\CodeCoverage.pipe.`, and sets a NULL DACL on it. It then runs `initrun.ps1 .\tools\run-tests-on-vm.ps1 -SkipPayload `, which deploys the instrumented payload and runs the tests on the desktop. In `finally` it calls `shutdown ` so the collector writes the `.coverage` file, downloads it and the collector log (also when the run failed), and closes the VM session. | | 4 | `Step4-Restore-Payload.ps1` | Always runs, from `finally`. It restores every original DLL, hash-verified, removes `static_covrun*.dll`, and does the same in the VM's deployed payload. Each distinct original is restored to its own copies. If any VM copy doesn't match afterwards, it fails and keeps `instrument-state.json`, so you can simply run it again. | | 5 | `Step5-Get-ComponentCoverage.ps1` | Merges the coverage to Cobertura (`dotnet-coverage merge -f cobertura`) and applies the per-module (file, line) dedup (`lib\Split-CoverageByModule.ps1`), the same correction that matches the ADO tab. It then filters to the component by `-ComponentPathRegex` and optionally compares with a baseline `corrected-per-file.csv`. Paths are matched repo-relative: the checkout root (`-RepoRoot`) or a build-agent root such as `C:\__w\1\s\` is removed, and `x86chk` versus `amd64chk` doesn't matter. It also produces function-level coverage (`lib\Get-FunctionCoverage.ps1`) and the HTML tree (`lib\Build-CoverageTreeReport.ps1`). | @@ -191,7 +195,7 @@ You can run the steps one at a time. Each writes its state or results under `\work`. - **Report:** parsing the Cobertura and native XML takes about 1-2 min each; skip the second with `-SkipFunctionCoverage`. @@ -211,7 +215,9 @@ Of 45 functions in the TitleBar `.cpp` files, 40 are fully covered, 4 partially, ## Gotchas (learned the hard way) - **Host session disconnects (Cloud PC / RDP):** if the host's session disconnects, the Hyper-V *enhanced session* stops delivering input. UI tests then fail with `InjectMouseInput ... Access is denied` and "Test window lost focus". Use `-MoveSessionToConsole`, which runs `tscon /dest:console` as SYSTEM inside the VM, so the VM desktop stays interactive. Reconnecting with vmconnect moves the session back. -- **Instrumenting is slow:** about 20 min and about 3 GB RAM for chk MUXC. Don't interrupt it; later runs reuse the cache. Use `-ForceReinstrument` after a rebuild if the hash didn't change but you want a new session id. +- **Instrumenting is slow:** about 20-50 min and about 3 GB RAM for chk MUXC. Don't interrupt it; later runs reuse the cache. Use `-ForceReinstrument` after a rebuild if the hash didn't change but you want a new session id. +- **Stale build:** if `BuildOutput` comes from another branch or an older commit, the payload may not contain the component's tests. The test check before Step 1 catches this; without it, TAEF reports "The selection criteria did not match any tests" on the VM and Step 3 fails after the long instrumentation. +- **Incremental deploy manifest:** `tools\run-tests-on-vm.ps1` only copies files whose size or timestamp changed since its last deploy (`TestPayload\.deploy-manifest--.json`). Step 4 restores the VM behind its back, and `static_covrun*.dll` always keep the tool's timestamp, so Step 3 removes the instrumented files from that manifest before every run. Without that, a rerun silently tests the original DLL (empty coverage), or the instrumented DLL is deployed without `static_covrun64.dll` and the test app crashes with "ClassFactory cannot supply requested class". - **Session id must match:** the DLL the test process loads must carry the collector's session id. If the coverage file is tiny or empty, check `work\raw\\collector.log` and confirm the app-local copy (`MUXControlsTestApp\Microsoft.UI.Xaml.Controls.dll`) is the instrumented one. - **Rebuilds overwrite the instrumented copy:** rebuilding `MUXControlsTestApp` re-stages an older Controls DLL into `BuildOutput\...\UnpackagedApps\MUXControlsTestApp`. That doesn't matter here because Step 2 overwrites the *payload* copies, but don't run `run-tests-on-vm.ps1` **without** `-SkipPayload` in between, because that recreates the payload. - **Expected failures still count toward coverage:** failing or ignored tests still execute product code until they fail, and Ignored tests don't run at all. @@ -239,6 +245,7 @@ Of 45 functions in the TitleBar `.cpp` files, 40 are fully covered, 4 partially, | `-MoveSessionToConsole` | off | Recommended on Cloud PC / RDP hosts | | `-RefreshPayload` | off | Run `test\CreateTestPayload.ps1` first | | `-ForceReinstrument` | off | Ignore the instrumented-DLL cache | +| `-SkipTestCheck` | off | Skip listing the matching tests in the payload before instrumenting | | `-SkipFunctionCoverage` | off | Skip the function-level merge | ## Files in this skill diff --git a/.github/skills/vm-component-coverage/scripts/Invoke-WinUIVmCoverage.ps1 b/.github/skills/vm-component-coverage/scripts/Invoke-WinUIVmCoverage.ps1 index 9207c51e3..f65d8e760 100644 --- a/.github/skills/vm-component-coverage/scripts/Invoke-WinUIVmCoverage.ps1 +++ b/.github/skills/vm-component-coverage/scripts/Invoke-WinUIVmCoverage.ps1 @@ -33,6 +33,8 @@ param( [switch]$MoveSessionToConsole, [switch]$RefreshPayload, [switch]$ForceReinstrument, + # Skip the pre-check that lists the matching tests in the payload before instrumenting. + [switch]$SkipTestCheck, [switch]$SkipFunctionCoverage ) $ErrorActionPreference = 'Stop' @@ -57,6 +59,32 @@ try { finally { Pop-Location } } + # Fail fast, before the long instrumentation, if the payload that will be deployed has no + # tests matching a filter (for example because the build predates the component's tests). + # Uses the same selection as runtests.ps1 on the VM. + $payloadDir = Join-Path $RepoRoot "TestPayload\$payloadFlavor" + if (-not $SkipTestCheck -and (Test-Path (Join-Path $payloadDir 'te.exe'))) { + Push-Location $payloadDir + # TAEF needs /select:"" passed verbatim; PowerShell 7's default quoting turns the + # query into a file name (TAEF then lists every test). + $savedArgPassing = $PSNativeCommandArgumentPassing + $PSNativeCommandArgumentPassing = 'Legacy' + try { + $testDlls = @(Get-ChildItem Test -Filter 'Microsoft.UI.Xaml.Tests.*.dll' -EA SilentlyContinue | ForEach-Object { "Test\$($_.Name)" }) + + @('Test\MUXControls.Test.dll', 'Test\UnpackagedApps\MUXControlsTestApp\MUXControlsTestApp.dll' | Where-Object { Test-Path $_ }) + foreach ($f in $TestFilter) { + $select = "@Name='*$f' and (@TestPass:IncludeOnlyOn='*Desktop*' OR (NOT(@TestPass:IncludeOnlyOn=*) AND NOT(@TestPass:ExcludeOn='*Desktop*')))" + $nameRx = '^' + ([regex]::Escape("*$f") -replace '\\\*', '.*') + '$' + $count = @(& .\te.exe @testDlls /list "/select:`"$select`"" 2>&1 | ForEach-Object { "$_".Trim() } | Where-Object { $_ -match $nameRx }).Count + Write-Host "== Test check: '$f' matches $count test(s) in TestPayload\$payloadFlavor" + if (-not $count) { + throw "No tests in TestPayload\$payloadFlavor match '$f'. Check the filter, and that the build is current and contains the component's tests (rebuild, then use -RefreshPayload). Use -SkipTestCheck to bypass this check." + } + } + } + finally { $PSNativeCommandArgumentPassing = $savedArgPassing; Pop-Location } + } + Write-Host "== Step 1: instrumentable $Module" if ($Module -eq 'Microsoft.UI.Xaml.Controls.dll') { $rl = & (Join-Path $scripts 'Step1-New-InstrumentableDll.ps1') -RepoRoot $RepoRoot -OutputDir (Join-Path $work 'relink') -Flavor $Flavor diff --git a/.github/skills/vm-component-coverage/scripts/Step3-Invoke-VmCoverageRun.ps1 b/.github/skills/vm-component-coverage/scripts/Step3-Invoke-VmCoverageRun.ps1 index 0a5d5ee8b..8dcd4d179 100644 --- a/.github/skills/vm-component-coverage/scripts/Step3-Invoke-VmCoverageRun.ps1 +++ b/.github/skills/vm-component-coverage/scripts/Step3-Invoke-VmCoverageRun.ps1 @@ -152,6 +152,21 @@ try { } -ArgumentList $vmRoot, $sid | ForEach-Object { Info " $_" } # ---- 4. run the tests with the repo's VM runner (payload already instrumented) ------------- + # run-tests-on-vm.ps1 deploys incrementally using a size+mtime manifest of what it copied last + # time. Step 4 restores the VM without updating that manifest, and static_covrun*.dll keep the + # tool's own timestamp, so a later run would skip them: the VM keeps the original DLL (empty + # coverage) or gets the instrumented DLL without its runtime (the app fails to activate MUXC + # types). Drop those entries so the instrumented files are always deployed. + $instrumentedRel = @($state.copies.relativePath) + @($state.runtimesAdded) + $manifestPattern = ".deploy-manifest-$($VMName -replace '[^a-zA-Z0-9]', '_')-*.json" + foreach ($mf in Get-ChildItem (Join-Path $RepoRoot 'TestPayload') -Filter $manifestPattern -File -EA SilentlyContinue) { + $entries = Get-Content $mf.FullName -Raw | ConvertFrom-Json + $keep = [ordered]@{} + foreach ($p in $entries.PSObject.Properties) { + if ($instrumentedRel -notcontains $p.Name -and $p.Name -notmatch '(^|\\)static_covrun\w*\.dll$') { $keep[$p.Name] = $p.Value } + } + ($keep | ConvertTo-Json -Depth 1) | Set-Content $mf.FullName -Encoding UTF8 + } $testLog = Join-Path $rawDir 'vm-test.log' Info "running tests: run-tests-on-vm.ps1 -SkipPayload $TestFilter (log: $testLog)" Push-Location $RepoRoot @@ -160,6 +175,12 @@ try { Tee-Object -FilePath $testLog | Where-Object { "$_" -match 'Summary:|EndGroup:|Tests (PASSED|FAILED)|Error|\[\d/3\]' } | ForEach-Object { Write-Host " $_" } } finally { Pop-Location } $summary = Select-String $testLog -Pattern 'Summary:\s+Total=' | Select-Object -Last 1 + if (-not $summary) { + if (Select-String $testLog -Pattern 'selection criteria did not match any tests' -Quiet) { + throw "No tests matched '$TestFilter' in the deployed payload. Check the filter, and that the build (and TestPayload) is current and contains the component's tests." + } + throw "The test run produced no TAEF summary; see $testLog." + } Info "test summary: $($summary.Line.Trim())" } finally { From 36c34192d51169b7bbb22109b8ecf5e0183940d3 Mon Sep 17 00:00:00 2001 From: ayushjai19 <244442986+ayushjai19@users.noreply.github.com> Date: Fri, 16 Oct 2026 18:15:18 +0530 Subject: [PATCH 04/10] NavigationView: add test for IsPaneOpen=false set before PaneDisplayMode=Left (#7609) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: ec9a38b2-2629-4a10-bfb5-552decfe6848 --- .../NavigationViewTests.cs | 71 +++++++++++++++++++ 1 file changed, 71 insertions(+) diff --git a/controls/dev/NavigationView/NavigationView_ApiTests/NavigationViewTests.cs b/controls/dev/NavigationView/NavigationView_ApiTests/NavigationViewTests.cs index 3b8fb0a4a..bed9d945b 100644 --- a/controls/dev/NavigationView/NavigationView_ApiTests/NavigationViewTests.cs +++ b/controls/dev/NavigationView/NavigationView_ApiTests/NavigationViewTests.cs @@ -543,6 +543,77 @@ namespace Microsoft.UI.Xaml.Tests.MUXControls.ApiTests } } + [TestMethod] + [TestProperty("Description", "Verifies IsPaneOpen=false set before PaneDisplayMode=Left is respected on launch (GitHub #7609).")] + public void VerifyIsPaneOpenFalseRespectedWhenSetBeforePaneDisplayModeLeft() + { + Log.Comment("Code: IsPaneOpen=false, then PaneDisplayMode=Left"); + VerifyLaunchState(() => + { + var navView = new NavigationView(); + navView.MenuItems.Add(new NavigationViewItem() { Content = "MenuItem" }); + navView.IsPaneOpen = false; + navView.PaneDisplayMode = NavigationViewPaneDisplayMode.Left; + return navView; + }, expectedIsPaneOpen: false); + + Log.Comment("XAML: IsPaneOpen='False' PaneDisplayMode='Left'"); + VerifyLaunchState(() => + { + return (NavigationView)XamlReader.Load(@" + + + + + "); + }, expectedIsPaneOpen: false); + + Log.Comment("Code: IsPaneOpen=false, PaneDisplayMode=LeftCompact, then PaneDisplayMode=Left"); + VerifyLaunchState(() => + { + var navView = new NavigationView(); + navView.MenuItems.Add(new NavigationViewItem() { Content = "MenuItem" }); + navView.IsPaneOpen = false; + navView.PaneDisplayMode = NavigationViewPaneDisplayMode.LeftCompact; + navView.PaneDisplayMode = NavigationViewPaneDisplayMode.Left; + return navView; + }, expectedIsPaneOpen: false); + + Log.Comment("Code: IsPaneOpen not set, PaneDisplayMode=Left (default open pane is unchanged)"); + VerifyLaunchState(() => + { + var navView = new NavigationView(); + navView.MenuItems.Add(new NavigationViewItem() { Content = "MenuItem" }); + navView.PaneDisplayMode = NavigationViewPaneDisplayMode.Left; + return navView; + }, expectedIsPaneOpen: true); + + void VerifyLaunchState(Func createNavView, bool expectedIsPaneOpen) + { + NavigationView navView = null; + RunOnUIThread.Execute(() => + { + navView = createNavView(); + Content = navView; + }); + IdleSynchronizer.Wait(); + + RunOnUIThread.Execute(() => + { + Verify.AreEqual(NavigationViewDisplayMode.Expanded, navView.DisplayMode); + Verify.AreEqual(expectedIsPaneOpen, navView.IsPaneOpen, "NavigationView.IsPaneOpen"); + + var splitView = VisualTreeUtils.FindVisualChildByName(navView, "RootSplitView") as SplitView; + Verify.IsNotNull(splitView); + Verify.AreEqual(expectedIsPaneOpen, splitView.IsPaneOpen, "RootSplitView.IsPaneOpen"); + }); + } + } + [TestMethod] public void VerifyDefaultsAndBasicSetting() { From 16a576bcc34e8951b88dcc88df74905ebae8b2ee Mon Sep 17 00:00:00 2001 From: ayushjai19 <244442986+ayushjai19@users.noreply.github.com> Date: Fri, 16 Oct 2026 20:32:20 +0530 Subject: [PATCH 05/10] NavigationView: keep IsPaneOpen=false when PaneDisplayMode is set before template is applied (#7609) Setting PaneDisplayMode reset m_wasForceClosed unconditionally, so an IsPaneOpen=false set earlier (e.g. ahead of PaneDisplayMode=Left in XAML) was forgotten and the expanded layout reopened the pane on launch. Only reset the flag once the template has been applied. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: ec9a38b2-2629-4a10-bfb5-552decfe6848 --- controls/dev/NavigationView/NavigationView.cpp | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/controls/dev/NavigationView/NavigationView.cpp b/controls/dev/NavigationView/NavigationView.cpp index 00fcafab3..7c5ba191e 100644 --- a/controls/dev/NavigationView/NavigationView.cpp +++ b/controls/dev/NavigationView/NavigationView.cpp @@ -4206,7 +4206,12 @@ void NavigationView::OnPropertyChanged(const winrt::DependencyPropertyChangedEve { // m_wasForceClosed is set to true because ToggleButton is clicked and Pane is closed. // When PaneDisplayMode is changed, reset the force flag to make the Pane can be opened automatically again. - m_wasForceClosed = false; + // Before the template is applied, the flag can only have come from the app setting IsPaneOpen=false + // (e.g. in XAML ahead of PaneDisplayMode), so keep it to honor that initial state. + if (m_appliedTemplate) + { + m_wasForceClosed = false; + } UpdatePaneToggleButtonVisibility(); UpdatePaneDisplayMode(auto_unbox(args.OldValue()), auto_unbox(args.NewValue())); From a20ec32bb3e0e85c284a391800053e5ac99a9126 Mon Sep 17 00:00:00 2001 From: ayushjai19 <244442986+ayushjai19@users.noreply.github.com> Date: Mon, 19 Oct 2026 16:32:04 +0530 Subject: [PATCH 06/10] NavigationView: extend #7609 test with trigger-audit cases (Auto launch, IsPaneOpen=true override, runtime LeftMinimal->Left) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: ec9a38b2-2629-4a10-bfb5-552decfe6848 --- .../NavigationViewTests.cs | 48 ++++++++++++++++++- 1 file changed, 47 insertions(+), 1 deletion(-) diff --git a/controls/dev/NavigationView/NavigationView_ApiTests/NavigationViewTests.cs b/controls/dev/NavigationView/NavigationView_ApiTests/NavigationViewTests.cs index bed9d945b..f46c3375f 100644 --- a/controls/dev/NavigationView/NavigationView_ApiTests/NavigationViewTests.cs +++ b/controls/dev/NavigationView/NavigationView_ApiTests/NavigationViewTests.cs @@ -592,7 +592,51 @@ namespace Microsoft.UI.Xaml.Tests.MUXControls.ApiTests return navView; }, expectedIsPaneOpen: true); - void VerifyLaunchState(Func createNavView, bool expectedIsPaneOpen) + Log.Comment("Code: IsPaneOpen=false, PaneDisplayMode=Left, then IsPaneOpen=true before load (open wins)"); + VerifyLaunchState(() => + { + var navView = new NavigationView(); + navView.MenuItems.Add(new NavigationViewItem() { Content = "MenuItem" }); + navView.IsPaneOpen = false; + navView.PaneDisplayMode = NavigationViewPaneDisplayMode.Left; + navView.IsPaneOpen = true; + return navView; + }, expectedIsPaneOpen: true); + + Log.Comment("Code: IsPaneOpen=false, PaneDisplayMode=LeftCompact, then Auto launching in Expanded"); + VerifyLaunchState(() => + { + var navView = new NavigationView(); + navView.MenuItems.Add(new NavigationViewItem() { Content = "MenuItem" }); + navView.IsPaneOpen = false; + navView.PaneDisplayMode = NavigationViewPaneDisplayMode.LeftCompact; + navView.PaneDisplayMode = NavigationViewPaneDisplayMode.Auto; + navView.ExpandedModeThresholdWidth = 600.0; + navView.CompactModeThresholdWidth = 400.0; + navView.Width = 610.0; + return navView; + }, expectedIsPaneOpen: false); + + Log.Comment("Runtime: after launching closed, LeftMinimal -> Left still opens the pane (post-template behavior unchanged)"); + var launchedNavView = VerifyLaunchState(() => + { + var navView = new NavigationView(); + navView.MenuItems.Add(new NavigationViewItem() { Content = "MenuItem" }); + navView.IsPaneOpen = false; + navView.PaneDisplayMode = NavigationViewPaneDisplayMode.Left; + return navView; + }, expectedIsPaneOpen: false); + + RunOnUIThread.Execute(() => launchedNavView.PaneDisplayMode = NavigationViewPaneDisplayMode.LeftMinimal); + IdleSynchronizer.Wait(); + RunOnUIThread.Execute(() => launchedNavView.PaneDisplayMode = NavigationViewPaneDisplayMode.Left); + IdleSynchronizer.Wait(); + RunOnUIThread.Execute(() => + { + Verify.IsTrue(launchedNavView.IsPaneOpen, "Pane should open on a runtime LeftMinimal -> Left switch"); + }); + + NavigationView VerifyLaunchState(Func createNavView, bool expectedIsPaneOpen) { NavigationView navView = null; RunOnUIThread.Execute(() => @@ -611,6 +655,8 @@ namespace Microsoft.UI.Xaml.Tests.MUXControls.ApiTests Verify.IsNotNull(splitView); Verify.AreEqual(expectedIsPaneOpen, splitView.IsPaneOpen, "RootSplitView.IsPaneOpen"); }); + + return navView; } } From ca15dfbdf5f07d8fd39b784ada88d934898d211a Mon Sep 17 00:00:00 2001 From: ayushjai19 <244442986+ayushjai19@users.noreply.github.com> Date: Tue, 20 Oct 2026 10:04:33 +0530 Subject: [PATCH 07/10] NavigationView: add #7609 test case for runtime LeftCompact -> Left after a closed launch Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: ec9a38b2-2629-4a10-bfb5-552decfe6848 --- .../NavigationViewTests.cs | 23 ++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/controls/dev/NavigationView/NavigationView_ApiTests/NavigationViewTests.cs b/controls/dev/NavigationView/NavigationView_ApiTests/NavigationViewTests.cs index f46c3375f..856ebe0d2 100644 --- a/controls/dev/NavigationView/NavigationView_ApiTests/NavigationViewTests.cs +++ b/controls/dev/NavigationView/NavigationView_ApiTests/NavigationViewTests.cs @@ -617,7 +617,28 @@ namespace Microsoft.UI.Xaml.Tests.MUXControls.ApiTests return navView; }, expectedIsPaneOpen: false); - Log.Comment("Runtime: after launching closed, LeftMinimal -> Left still opens the pane (post-template behavior unchanged)"); + Log.Comment("Runtime: after launching closed, LeftCompact -> Left opens the pane (post-template PaneDisplayMode change still clears the force-close flag)"); + var launchedNavViewCompact = VerifyLaunchState(() => + { + var navView = new NavigationView(); + navView.MenuItems.Add(new NavigationViewItem() { Content = "MenuItem" }); + navView.IsPaneOpen = false; + navView.PaneDisplayMode = NavigationViewPaneDisplayMode.Left; + return navView; + }, expectedIsPaneOpen: false); + + RunOnUIThread.Execute(() => launchedNavViewCompact.PaneDisplayMode = NavigationViewPaneDisplayMode.LeftCompact); + IdleSynchronizer.Wait(); + RunOnUIThread.Execute(() => launchedNavViewCompact.PaneDisplayMode = NavigationViewPaneDisplayMode.Left); + IdleSynchronizer.Wait(); + RunOnUIThread.Execute(() => + { + Verify.IsTrue(launchedNavViewCompact.IsPaneOpen, "NavigationView.IsPaneOpen after runtime LeftCompact -> Left"); + var splitView = VisualTreeUtils.FindVisualChildByName(launchedNavViewCompact, "RootSplitView") as SplitView; + Verify.IsTrue(splitView.IsPaneOpen, "RootSplitView.IsPaneOpen after runtime LeftCompact -> Left"); + }); + + Log.Comment("Runtime: after launching closed, LeftMinimal -> Left opens the pane (by-design explicit OpenPane in UpdatePaneDisplayMode)"); var launchedNavView = VerifyLaunchState(() => { var navView = new NavigationView(); From c0ef2a40029bbb73638a94b976fb0b1ef145e556 Mon Sep 17 00:00:00 2001 From: Satwik Kumar Sharma Date: Tue, 20 Oct 2026 17:26:48 +0530 Subject: [PATCH 08/10] Address second review round on the coverage skill - Rename the skill to code-coverage-calculation (review feedback). - Move Set-VmCredential.ps1 to tools\ so other VM tooling can share it; it now replaces the cached credential only after every check (including the active-desktop check) passes. - Support Microsoft.UI.Xaml.Controls.Tabular.dll (TableView and the tabular helpers): relink from controls\dev\dll-tabular, and pick the module in Gate 2 from controls\Tabular.ProjectImports.targets. - Step 2: back up every copy and persist instrument-state.json before the first overwrite; Step 4 skips copies that were never overwritten. The orchestrator restores a previous interrupted run before starting. - Orchestrator: fail on a failed -RefreshPayload and on an incomplete payload; include IXMPTestApp.appx in the test check and fail on te.exe errors; a restore failure now fails the command after the report is written, while an earlier failure is still reported as the primary error; default -OutputDir to BuildOutput\Coverage\\ (git-ignored). - Step 5: -BaselineRepoRoot, anchor fallback keys on controls/dxaml/BuildOutput before src/test, and warn when a baseline matches none or under half of the component files. - SKILL.md: tell the user what the run does on the VM (NULL DACL pipe, scheduled tasks, C:\WinUICoverage) before every run; clarify -SkipPayload. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: fcbd0fa8-6888-4e0c-96aa-0c60b83530e6 --- .../SKILL.md | 102 +++++++++++------- .../scripts/Invoke-WinUIVmCoverage.ps1 | 97 +++++++++++++---- .../scripts/Step1-New-InstrumentableDll.ps1 | 0 .../scripts/Step2-Instrument-Payload.ps1 | 32 ++++-- .../scripts/Step3-Invoke-VmCoverageRun.ps1 | 6 +- .../scripts/Step4-Restore-Payload.ps1 | 6 +- .../scripts/Step5-Get-ComponentCoverage.ps1 | 33 ++++-- .../scripts/lib/Build-CoverageTreeReport.ps1 | 0 .../lib/Build-FunctionCoverageTreeReport.ps1 | 0 .../scripts/lib/Get-FunctionCoverage.ps1 | 0 .../scripts/lib/Repair-CoverageDuplicates.ps1 | 0 .../scripts/lib/Set-CoveragePipeAcl.ps1 | 0 .../scripts/lib/Split-CoverageByModule.ps1 | 0 .../scripts => tools}/Set-VmCredential.ps1 | 34 +++--- 14 files changed, 221 insertions(+), 89 deletions(-) rename .github/skills/{vm-component-coverage => code-coverage-calculation}/SKILL.md (69%) rename .github/skills/{vm-component-coverage => code-coverage-calculation}/scripts/Invoke-WinUIVmCoverage.ps1 (54%) rename .github/skills/{vm-component-coverage => code-coverage-calculation}/scripts/Step1-New-InstrumentableDll.ps1 (100%) rename .github/skills/{vm-component-coverage => code-coverage-calculation}/scripts/Step2-Instrument-Payload.ps1 (86%) rename .github/skills/{vm-component-coverage => code-coverage-calculation}/scripts/Step3-Invoke-VmCoverageRun.ps1 (98%) rename .github/skills/{vm-component-coverage => code-coverage-calculation}/scripts/Step4-Restore-Payload.ps1 (91%) rename .github/skills/{vm-component-coverage => code-coverage-calculation}/scripts/Step5-Get-ComponentCoverage.ps1 (83%) rename .github/skills/{vm-component-coverage => code-coverage-calculation}/scripts/lib/Build-CoverageTreeReport.ps1 (100%) rename .github/skills/{vm-component-coverage => code-coverage-calculation}/scripts/lib/Build-FunctionCoverageTreeReport.ps1 (100%) rename .github/skills/{vm-component-coverage => code-coverage-calculation}/scripts/lib/Get-FunctionCoverage.ps1 (100%) rename .github/skills/{vm-component-coverage => code-coverage-calculation}/scripts/lib/Repair-CoverageDuplicates.ps1 (100%) rename .github/skills/{vm-component-coverage => code-coverage-calculation}/scripts/lib/Set-CoveragePipeAcl.ps1 (100%) rename .github/skills/{vm-component-coverage => code-coverage-calculation}/scripts/lib/Split-CoverageByModule.ps1 (100%) rename {.github/skills/vm-component-coverage/scripts => tools}/Set-VmCredential.ps1 (72%) diff --git a/.github/skills/vm-component-coverage/SKILL.md b/.github/skills/code-coverage-calculation/SKILL.md similarity index 69% rename from .github/skills/vm-component-coverage/SKILL.md rename to .github/skills/code-coverage-calculation/SKILL.md index a1fa3d8f0..9862a3f52 100644 --- a/.github/skills/vm-component-coverage/SKILL.md +++ b/.github/skills/code-coverage-calculation/SKILL.md @@ -1,9 +1,11 @@ --- -name: vm-component-coverage +name: code-coverage-calculation description: >- EXPERIMENTAL. Calculate code coverage of a WinUI control or component (for example "calculate coverage of control TitleBar") by running only that component's tests on a local - Hyper-V VM against the local build. Reports per-file and per-function line coverage that matches + Hyper-V VM against the local build. Supports controls in Microsoft.UI.Xaml.Controls.dll and + Microsoft.UI.Xaml.Controls.Tabular.dll, and dxaml types in Microsoft.ui.xaml.dll. Reports per-file + and per-function line coverage that matches the ADO Code Coverage tab. Use for "calculate/measure/get coverage of ", "how much do 's tests cover", or "coverage before/after my tests" when you have a local build and a VM. --- @@ -12,13 +14,13 @@ description: >- ## Agent workflow (follow in order; every gate is mandatory) -All commands run from the repo root. `` = `.github\skills\vm-component-coverage`. +All commands run from the repo root. `` = `.github\skills\code-coverage-calculation`. ### Gate 0 - Prerequisites (check; stop and tell the user on failure) | Check | How | If missing | |---|---|---| -| Local build of the flavor (default `amd64chk`) | `BuildOutput\bin\amd64chk\Product\Microsoft.UI.Xaml.Controls.dll` and `BuildOutput\obj\amd64chk\controls\dev\dll\**\link.command.1.tlog` exist | Ask before building (10+ min), then run `.\initrun.ps1 .\build.cmd /q` from the repo root. | +| Local build of the flavor (default `amd64chk`), from the current branch | The module DLL exists under `BuildOutput\bin\amd64chk\Product\`, and for MUXC/Tabular the link log `BuildOutput\obj\amd64chk\controls\dev\dll` (or `dll-tabular`) `\**\link.command.1.tlog` exists | Ask before building (10+ min), then run `.\initrun.ps1 .\build.cmd /q` from the repo root. | | VS Enterprise code coverage | `Microsoft.CodeCoverage.Console.exe` under `%ProgramFiles%\Microsoft Visual Studio` | Stop; the user must install it. | | `dotnet-coverage` | `Get-Command dotnet-coverage` | Tell the user: `dotnet tool install --global dotnet-coverage` | | Hyper-V rights | `Get-VM` succeeds | Stop. Ask the user to add their account (from `whoami`) to **Hyper-V Administrators** in an elevated PowerShell window, then sign out and back in. Never elevate or add group members yourself. | @@ -46,7 +48,7 @@ username or password from memory, an earlier session, or an earlier run without - **No cache, or the user chooses different details:** ask for the **VM name** and **VM username** (the plain account name as shown on the VM, with no `.\` or `VM\` prefix). - If the user declines or cancels, stop. Do not continue with a default. -3. Run `\scripts\Set-VmCredential.ps1 -VMName -UserName `: +3. Run `tools\Set-VmCredential.ps1 -VMName -UserName ` (a shared repo tool): - For new details, add `-Force`. The script asks for the **password in a separate window** (never in chat; chat text is kept in session history) and writes the encrypted per-user cache `%USERPROFILE%\.winui-test\vmcred-.xml`. This is the cache `tools\run-tests-on-vm.ps1` and Steps 3-4 read; @@ -59,11 +61,15 @@ username or password from memory, an earlier session, or an earlier run without ### Gate 2 - Resolve the component and its tests -**A. `controls\dev` (MUXC, `Microsoft.UI.Xaml.Controls.dll`)** +**A. `controls\dev` (`Microsoft.UI.Xaml.Controls.dll` or `Microsoft.UI.Xaml.Controls.Tabular.dll`)** 1. Folder: match the name case-insensitively against `controls\dev\*` folders. If there's no match, search `controls\dev\*\*.idl` for `runtimeclass ` and use that folder (for example `ItemsRepeater` resolves to `Repeater`). If there are zero or several candidates, ask the user and offer the candidates. + **Module:** if `controls\Tabular.ProjectImports.targets` imports `dev\\.vcxitems` (today: TableView, + TabularShaping, ShapedItemsSource, GroupedSourceAdapter, HierarchicalSourceAdapter, SortIndicator, + ResizeGripper), the code is in `Microsoft.UI.Xaml.Controls.Tabular.dll`: pass + `-Module Microsoft.UI.Xaml.Controls.Tabular.dll`. Otherwise it's `Microsoft.UI.Xaml.Controls.dll` (the default). 2. Tests: list the `[TestClass]` classes (with their namespaces) in `controls\dev\\APITests\**\*.cs` and `controls\dev\\InteractionTests\**\*.cs`. TAEF test names are `..`, and `runtests` selects `@Name='*'`. @@ -74,10 +80,10 @@ username or password from memory, an earlier session, or an earlier run without - If the folder has no tests, tell the user and ask which tests to run. 3. `-ComponentPathRegex`: take every `runtimeclass` `` declared in `controls\dev\\*.idl` and match the folder plus both kinds of generated sources: - `\\controls\\dev\\\\|\\controls\\dev\\Generated\\(||...)\.|\\dll\\generated\\(.+\\)?(||...)\.g\.` + `\\controls\\dev\\\\|\\controls\\dev\\Generated\\(||...)\.|\\dll(-tabular)?\\generated\\(.+\\)?(||...)\.g\.` The second part covers `controls\dev\Generated\.properties.*` and the third covers the build-generated - `BuildOutput\obj\\controls\dev\dll\generated\...\.g.cpp/.h` files. -4. Show the resolved plan (folder, test classes, filter(s), regex), then proceed. Ask only if + `BuildOutput\obj\\controls\dev\dll\generated\...\.g.cpp/.h` (or `dll-tabular\generated`) files. +4. Show the resolved plan (folder, module, test classes, filter(s), regex), then proceed. Ask only if something is ambiguous. 5. The build must contain those tests. Before instrumenting, the orchestrator lists the matching tests in `TestPayload\` with TAEF (the same selection `runtests` uses on the VM) and stops in seconds if a @@ -96,31 +102,49 @@ username or password from memory, an earlier session, or an earlier run without `Microsoft.ui.xaml.dll`. Warn that MUX is about 71 MB, so the first instrumentation is likely to take well over 40 minutes. **Do not run until the user approves.** Revise the plan with their feedback. -**C. Found in neither place:** ask the user which source folder and tests to use. +**C. Found in neither place:** ask the user which source folder and tests to use. Other DLLs (for example +`Microsoft.UI.Xaml.Phone.dll`) aren't supported yet; say so instead of measuring a different module. + +### Tell the user what the run does on the VM (before every run) + +The run makes changes on the test VM, so say this once before starting: +- It creates `C:\WinUICoverage` (coverage tools, logs and output) and runs short-lived scheduled tasks + as the VM user. With `-MoveSessionToConsole` it also runs `tscon` as SYSTEM to move the session to the console. +- It opens the coverage collector's named pipe (`\\.\pipe\CodeCoverage.pipe.`) with a NULL DACL, so + low-integrity test processes can report coverage. **Any local user on the VM can connect to that pipe + until the run ends.** This is intended for a dedicated test VM; don't use it on a shared machine. +- It temporarily replaces the module in the local `TestPayload` and in the VM's deployed payload with the + instrumented copy, and restores both afterwards (hash-checked). `BuildOutput` and sources are never changed. ### Run -- Output: `\CodeCoverageOutput\` (already ignored by `.gitignore`). -- Add `-RefreshPayload` if `TestPayload\x64chk` is missing or older than the build output. +- Output: `\BuildOutput\Coverage\\` by default (git-ignored with `BuildOutput`). + Its `work\` folder holds the relinked and instrumented DLLs and the payload backups (several hundred MB). +- Add `-RefreshPayload` if `TestPayload\x64chk` is missing or older than the build output. A failed + refresh stops the run. - Add `-MoveSessionToConsole` when the host is an RDP or Cloud PC session (`$env:SESSIONNAME -like 'RDP-*'`). -- Tell the user the expected time first (about 30-60 min the first time for MUXC, mostly instrumentation; about 6 min with the cache). - Run it async with a long wait. **Never stop the process during Step 2 or 3**; the restore runs in `finally`. +- Tell the user the expected time first (about 30-60 min the first time for MUXC, mostly instrumentation; a few + minutes for the 6.5 MB Tabular DLL; about 6-10 min with the cache). + Run it async with a long wait. **Never stop the process during Step 2 or 3**; the restore runs in `finally`, + and if a run is killed anyway, the next run restores the payload before doing anything else. Run from the repo root in PowerShell, invoking the script directly. Don't use `pwsh -File`: it turns `'a','b'` into a single string. ```powershell -& .\.github\skills\vm-component-coverage\scripts\Invoke-WinUIVmCoverage.ps1 ` +& .\.github\skills\code-coverage-calculation\scripts\Invoke-WinUIVmCoverage.ps1 ` -RepoRoot (Get-Location).Path -VMName -TestFilter ''[,''] ` - -ComponentName -ComponentPathRegex '' -Module ` - -OutputDir .\CodeCoverageOutput\ [-RefreshPayload] [-MoveSessionToConsole] + -ComponentName -ComponentPathRegex '' ` + -Module ` + [-RefreshPayload] [-MoveSessionToConsole] ``` ### Report -- Confirm Step 4 restored the payload. If the log shows `RESTORE FAILED`, tell the user prominently. - `instrument-state.json` is kept in that case, so offer to rerun - `\scripts\Step4-Restore-Payload.ps1 -RepoRoot -WorkDir \work -VMName `. +- If restoring the payload or VM fails, the command **fails** (after writing the report, if coverage was + collected) and `instrument-state.json` is kept. Tell the user prominently, and offer to rerun + `\scripts\Step4-Restore-Payload.ps1 -RepoRoot -WorkDir \work -VMName ` + (or simply rerun the coverage command, which restores first). - From `report-\coverage-summary.md`, present the component total, the per-file table, and the uncovered and partially covered functions. Point to `TreeReport\coverage-tree.html` and the CSVs. - Check the test summary. If `Total=0`, the filter matched nothing; fix the filter instead of reporting 0 %. @@ -151,19 +175,20 @@ already use for WinUI tests. Everything it needs is in this folder. ## Quick start ```powershell -cd \.github\skills\vm-component-coverage\scripts +cd \.github\skills\code-coverage-calculation\scripts .\Invoke-WinUIVmCoverage.ps1 ` -RepoRoot ` -VMName ` -TestFilter '*.TitleBarTests.*' ` -ComponentName TitleBar ` -ComponentPathRegex '\\controls\\dev\\TitleBar\\|\\Generated\\TitleBar|\\generated\\Controls\\TitleBar' ` - -OutputDir \CodeCoverageOutput\TitleBar ` -MoveSessionToConsole ` -BaselinePerFileCsv '\PerModule\Microsoft.UI.Xaml.Controls\corrected-per-file.csv' ` -BaselineLabel 'Full test pass' ``` +Output goes to `\BuildOutput\Coverage\amd64chk\TitleBar` unless you pass `-OutputDir`. If the baseline came from another checkout, also pass `-BaselineRepoRoot `. + Open `\report-\coverage-summary.md`. Other outputs in that folder: - `component-per-file.csv` - `component-functions.csv` @@ -185,18 +210,19 @@ Open `\report-\coverage-summary.md`. Other outputs in that | Step | Script | Notes | |---|---|---| -| 1 | `Step1-New-InstrumentableDll.ps1` | **MUXC only.** chk MUXC is not instrumentable: the tool prints "*Unable to obtain debug information. Link with the /PROFILE linker switch.*" and then **exits 0** with the image untouched. It needs `/DEBUGTYPE:CV,FIXUP` with `/INCREMENTAL:NO` at link time, and this skill never edits repo files, so the script re-runs the **exact link command MSBuild recorded** (`link.command.1.tlog`) with `/DEBUGTYPE:CV,FIXUP` added and `/INCREMENTAL:NO`. `/OUT`, `/PDB`, `/IMPLIB` and `/MAP` are redirected to `work\relink`, and `/WINMD:NO` is set. It takes about 15 s, and BuildOutput is never touched. MUX (`Microsoft.ui.xaml.dll`) already links with `/debugtype:cv,fixup,pdata` and skips this step. | -| 2 | `Step2-Instrument-Payload.ps1` | Static native instrumentation with a fresh 16-hex session id. **This is slow for a 38 MB chk DLL (about 20-50 min)**, so the result is cached in `work\instrumented`, keyed by the input DLL's SHA256, and reused on later runs together with its baked-in session id. The cache is reused only while the tool that instrumented it still exists at the same version. The script catches the "exit 0 but not instrumented" case. It backs up and then overwrites **every** payload copy of the DLL, plus `static_covrun64.dll`, because each test app loads its own co-located copy (`Test\UnpackagedApps\MUXControlsTestApp\`). Backups are refreshed from the payload whenever a copy isn't instrumented, so a rebuilt payload is never restored to an older DLL. | -| 3 | `Step3-Invoke-VmCoverageRun.ps1` | Stages the collector on the VM once. It starts `collect --server-mode --session-id ` as the VM user through an interactive scheduled task, waits for `\\.\pipe\CodeCoverage.pipe.`, and sets a NULL DACL on it. It then runs `initrun.ps1 .\tools\run-tests-on-vm.ps1 -SkipPayload `, which deploys the instrumented payload and runs the tests on the desktop. In `finally` it calls `shutdown ` so the collector writes the `.coverage` file, downloads it and the collector log (also when the run failed), and closes the VM session. | -| 4 | `Step4-Restore-Payload.ps1` | Always runs, from `finally`. It restores every original DLL, hash-verified, removes `static_covrun*.dll`, and does the same in the VM's deployed payload. Each distinct original is restored to its own copies. If any VM copy doesn't match afterwards, it fails and keeps `instrument-state.json`, so you can simply run it again. | -| 5 | `Step5-Get-ComponentCoverage.ps1` | Merges the coverage to Cobertura (`dotnet-coverage merge -f cobertura`) and applies the per-module (file, line) dedup (`lib\Split-CoverageByModule.ps1`), the same correction that matches the ADO tab. It then filters to the component by `-ComponentPathRegex` and optionally compares with a baseline `corrected-per-file.csv`. Paths are matched repo-relative: the checkout root (`-RepoRoot`) or a build-agent root such as `C:\__w\1\s\` is removed, and `x86chk` versus `amd64chk` doesn't matter. It also produces function-level coverage (`lib\Get-FunctionCoverage.ps1`) and the HTML tree (`lib\Build-CoverageTreeReport.ps1`). | +| 1 | `Step1-New-InstrumentableDll.ps1` | **MUXC and Tabular** (`Microsoft.UI.Xaml.Controls.Tabular.dll`, relinked from `controls\dev\dll-tabular`). chk MUXC is not instrumentable: the tool prints "*Unable to obtain debug information. Link with the /PROFILE linker switch.*" and then **exits 0** with the image untouched. It needs `/DEBUGTYPE:CV,FIXUP` with `/INCREMENTAL:NO` at link time, and this skill never edits repo files, so the script re-runs the **exact link command MSBuild recorded** (`link.command.1.tlog`) with `/DEBUGTYPE:CV,FIXUP` added and `/INCREMENTAL:NO`. `/OUT`, `/PDB`, `/IMPLIB` and `/MAP` are redirected to `work\relink`, and `/WINMD:NO` is set. It takes about 15 s, and BuildOutput is never touched. MUX (`Microsoft.ui.xaml.dll`) already links with `/debugtype:cv,fixup,pdata` and skips this step. | +| 2 | `Step2-Instrument-Payload.ps1` | Static native instrumentation with a fresh 16-hex session id. **This is slow for a 38 MB chk DLL (about 20-50 min)**, so the result is cached in `work\instrumented`, keyed by the input DLL's SHA256, and reused on later runs together with its baked-in session id. The cache is reused only while the tool that instrumented it still exists at the same version. The script catches the "exit 0 but not instrumented" case. It backs up every payload copy and writes `instrument-state.json` **before** overwriting anything (so an interrupted run can always be restored), then overwrites **every** payload copy of the DLL, plus `static_covrun64.dll`, because each test app loads its own co-located copy (`Test\UnpackagedApps\MUXControlsTestApp\`). Backups are refreshed from the payload whenever a copy isn't instrumented, so a rebuilt payload is never restored to an older DLL. | +| 3 | `Step3-Invoke-VmCoverageRun.ps1` | Stages the collector on the VM once. It starts `collect --server-mode --session-id ` as the VM user through an interactive scheduled task, waits for `\\.\pipe\CodeCoverage.pipe.`, and sets a NULL DACL on it. It then runs `initrun.ps1 .\tools\run-tests-on-vm.ps1 -SkipPayload `. `-SkipPayload` only stops the runner from regenerating `TestPayload` from BuildOutput (which would overwrite the instrumented DLLs); the runner still copies the changed payload files to the VM and runs the tests on the desktop. In `finally` it calls `shutdown ` so the collector writes the `.coverage` file, downloads it and the collector log (also when the run failed), and closes the VM session. | +| 4 | `Step4-Restore-Payload.ps1` | Always runs, from `finally`, and again at the start of the next run if a previous run was killed before restoring. It restores every original DLL (copies that were never overwritten are skipped), hash-verified, removes `static_covrun*.dll`, and does the same in the VM's deployed payload. Each distinct original is restored to its own copies. If any VM copy doesn't match afterwards, it fails and keeps `instrument-state.json`, so you can simply run it again. A restore failure makes the whole command fail. | +| 5 | `Step5-Get-ComponentCoverage.ps1` | Merges the coverage to Cobertura (`dotnet-coverage merge -f cobertura`) and applies the per-module (file, line) dedup (`lib\Split-CoverageByModule.ps1`), the same correction that matches the ADO tab. It then filters to the component by `-ComponentPathRegex` and optionally compares with a baseline `corrected-per-file.csv`. Paths are matched repo-relative: the checkout root (`-RepoRoot`, or `-BaselineRepoRoot` for a baseline from another checkout) or a build-agent root such as `C:\__w\1\s\` is removed, the report warns if the baseline matches none or under half of the component files, and `x86chk` versus `amd64chk` doesn't matter. It also produces function-level coverage (`lib\Get-FunctionCoverage.ps1`) and the HTML tree (`lib\Build-CoverageTreeReport.ps1`). | You can run the steps one at a time. Each writes its state or results under `\work`. ## Timing - **First run:** about 30-60 min, almost all of it Step 2 instrumenting a 38 MB chk MUXC (20-50 min; it runs on a single core). The rest is a few seconds to relink, then deploy, the tests, flush and the report. -- **Later runs with an unchanged build:** about 6 min. The relink and the instrumented DLL are both reused from `\work`. +- **Tabular (6.5 MB):** instrumentation takes only a few minutes. +- **Later runs with an unchanged build:** about 6-10 min. The relink and the instrumented DLL are both reused from `\work`. - **Report:** parsing the Cobertura and native XML takes about 1-2 min each; skip the second with `-SkipFunctionCoverage`. ## Verified result (TitleBar, 2026-10-06) @@ -212,6 +238,8 @@ You can run the steps one at a time. Each writes its state or results under ` /dest:console` as SYSTEM inside the VM, so the VM desktop stays interactive. Reconnecting with vmconnect moves the session back. @@ -223,7 +251,7 @@ Of 45 functions in the TitleBar `.cpp` files, 40 are fully covered, 4 partially, - **Expected failures still count toward coverage:** failing or ignored tests still execute product code until they fail, and Ignored tests don't run at all. - **Known crash at exit is harmless:** TitleBar runs fail-fast at app exit, which shows as cleanup errors. Coverage still flushes because the collector is out of process. - **Numbers differ from CI:** a local VM run uses a different OS build, flavor (x64 vs CI's x86) and test subset. Compare per file, not the module total. -- **NULL DACL:** the collector pipe is open to every local user until shutdown. That's fine on a test VM, but avoid it on shared machines. +- **NULL DACL:** the collector pipe is open to every local user until shutdown, so that low-integrity test processes can connect (otherwise coverage is empty with ACCESS_DENIED). That's fine on a dedicated test VM, but avoid it on shared machines; the agent workflow tells the user before every run. - **`cmd /c` quoting:** starting the collector via `cmd /c "" ... > ""` silently fails, because cmd strips the outer quotes. Step 3 therefore writes a `.cmd` wrapper. If the collector pipe never appears, check that wrapper and `C:\WinUICoverage\out\collector-.log` on the VM. - **Same tool for instrument and collect:** the collector must be the same `Microsoft.CodeCoverage.Console.exe` version that instrumented the DLL. Step 2 records the tool, and Step 3 stages that exact version to `C:\WinUICoverage\CoverageTool\`. If that tool is gone or changed version (for example after a VS update), Step 2 re-instruments instead of reusing the cache. When several VS installs exist, the highest file version wins; path string order is not used. - **Relink is not byte-deterministic:** Step 1 therefore reuses its previous output while the recorded link inputs are unchanged. Otherwise the Step 2 cache, keyed by DLL hash, would miss and re-instrument for 45 minutes. @@ -237,30 +265,30 @@ Of 45 functions in the TitleBar `.cpp` files, 40 are fully covered, 4 partially, | `-RepoRoot` | required | WinUI checkout with a completed local build | | `-VMName` | required | Hyper-V VM | | `-TestFilter` | required | One or more filters passed to `run-tests-on-vm.ps1`, e.g. `*.TitleBarTests.*`. With several, Step 3 runs once per filter and Step 5 merges the `.coverage` files. | -| `-OutputDir` | required | Work and report root | +| `-OutputDir` | `\BuildOutput\Coverage\\` | Work and report root (git-ignored by default) | | `-ComponentName` / `-ComponentPathRegex` | TitleBar | Regex on the source path selecting the component's files | -| `-Module` | `Microsoft.UI.Xaml.Controls.dll` | or `Microsoft.ui.xaml.dll` | +| `-Module` | `Microsoft.UI.Xaml.Controls.dll` | or `Microsoft.UI.Xaml.Controls.Tabular.dll` (TableView and the tabular helpers), or `Microsoft.ui.xaml.dll` | | `-Flavor` | `amd64chk` | Build flavor; payload `x64chk` | -| `-BaselinePerFileCsv` / `-BaselineLabel` | none | A `corrected-per-file.csv` from another coverage run to compare with, e.g. `PerModule\\corrected-per-file.csv` from an earlier run of this skill, or `lib\Split-CoverageByModule.ps1` run over a CI build's merged Cobertura file | +| `-BaselinePerFileCsv` / `-BaselineLabel` / `-BaselineRepoRoot` | none | A `corrected-per-file.csv` from another coverage run to compare with, e.g. `PerModule\\corrected-per-file.csv` from an earlier run of this skill, or `lib\Split-CoverageByModule.ps1` run over a CI build's merged Cobertura file | | `-MoveSessionToConsole` | off | Recommended on Cloud PC / RDP hosts | -| `-RefreshPayload` | off | Run `test\CreateTestPayload.ps1` first | +| `-RefreshPayload` | off | Run `test\CreateTestPayload.ps1` first; stops if it fails | | `-ForceReinstrument` | off | Ignore the instrumented-DLL cache | -| `-SkipTestCheck` | off | Skip listing the matching tests in the payload before instrumenting | +| `-SkipTestCheck` | off | Skip listing the matching tests in the payload (same test files and selection as `runtests`) before instrumenting | | `-SkipFunctionCoverage` | off | Skip the function-level merge | ## Files in this skill | File | Purpose | |---|---| -| `scripts\Invoke-WinUIVmCoverage.ps1` | One-command orchestrator for Steps 1-5. Step 3 runs once per `-TestFilter`. The payload and VM are restored in `finally`. | -| `scripts\Step1-New-InstrumentableDll.ps1` | Relinks MUXC with `/DEBUGTYPE:CV,FIXUP` from the recorded link command, outside BuildOutput. | +| `scripts\Invoke-WinUIVmCoverage.ps1` | One-command orchestrator for Steps 1-5. Step 3 runs once per `-TestFilter`. The payload and VM are restored in `finally` (and at the start of the next run if a run was killed). | +| `scripts\Step1-New-InstrumentableDll.ps1` | Relinks MUXC or Tabular with `/DEBUGTYPE:CV,FIXUP` from the recorded link command, outside BuildOutput. | | `scripts\Step2-Instrument-Payload.ps1` | Static instrumentation (cached) and broadcast into `TestPayload\`, with backups. | | `scripts\Step3-Invoke-VmCoverageRun.ps1` | Starts the collector on the VM, runs the tests through `tools\run-tests-on-vm.ps1 -SkipPayload`, shuts the collector down and downloads the coverage file. | | `scripts\Step4-Restore-Payload.ps1` | Restores the payload and the VM copies, hash-verified. | -| `scripts\Set-VmCredential.ps1` | Gate 1 helper: validates the VM, takes the password in a separate window, writes the encrypted credential cache, and checks the logon and the active desktop. | | `scripts\Step5-Get-ComponentCoverage.ps1` | Merge, per-module dedup, component filter, baseline comparison, function coverage, HTML tree and `coverage-summary.md`. | | `scripts\lib\Set-CoveragePipeAcl.ps1` | Puts a NULL DACL on the collector's named pipe so low-integrity TAEF test processes can connect. | | `scripts\lib\Repair-CoverageDuplicates.ps1`, `Split-CoverageByModule.ps1` | Deduplicate merged Cobertura by (file, line), overall and per module, so the numbers match the Azure DevOps Code Coverage tab. | | `scripts\lib\Get-FunctionCoverage.ps1` | Per-function coverage from the native XML merge. | | `scripts\lib\Build-CoverageTreeReport.ps1`, `Build-FunctionCoverageTreeReport.ps1` | Single-file HTML folder/file (and function) tree reports. | +Shared repo tool used by Gate 1: `tools\Set-VmCredential.ps1` validates the VM, takes the password in a separate window, writes the encrypted credential cache (only after every check passes), and checks the logon and the active desktop. diff --git a/.github/skills/vm-component-coverage/scripts/Invoke-WinUIVmCoverage.ps1 b/.github/skills/code-coverage-calculation/scripts/Invoke-WinUIVmCoverage.ps1 similarity index 54% rename from .github/skills/vm-component-coverage/scripts/Invoke-WinUIVmCoverage.ps1 rename to .github/skills/code-coverage-calculation/scripts/Invoke-WinUIVmCoverage.ps1 index f65d8e760..fe9a4bae5 100644 --- a/.github/skills/vm-component-coverage/scripts/Invoke-WinUIVmCoverage.ps1 +++ b/.github/skills/code-coverage-calculation/scripts/Invoke-WinUIVmCoverage.ps1 @@ -6,15 +6,24 @@ .DESCRIPTION relink (instrumentable copy) -> instrument + broadcast into TestPayload -> collector + tests on VM -> download .coverage -> restore payload/VM -> component report (optionally vs. a baseline). - The payload and the VM are restored in a finally block even if the run fails. + * The payload and the VM are restored in a finally block even if the run fails. If a previous + run was killed before it could restore, this script restores that first. + * A restore failure fails the command (after the report is written, if coverage was collected). + * Only the generated TestPayload\ folder and the VM's deployed payload are modified; + BuildOutput and repo sources are never touched. .EXAMPLE - # TitleBar coverage from the TitleBar tests only, compared to a baseline from another coverage run + # TitleBar coverage from the TitleBar tests only (output: BuildOutput\Coverage\amd64chk\TitleBar) .\Invoke-WinUIVmCoverage.ps1 -RepoRoot C:\src\microsoft-ui-xaml -VMName MyTestVM ` -TestFilter '*.TitleBarTests.*' -ComponentName TitleBar ` -ComponentPathRegex '\\controls\\dev\\TitleBar\\|\\Generated\\TitleBar|\\generated\\Controls\\TitleBar' ` - -OutputDir C:\src\microsoft-ui-xaml\CodeCoverageOutput\TitleBar -MoveSessionToConsole ` - -BaselinePerFileCsv C:\cov\baseline\PerModule\Microsoft.UI.Xaml.Controls\corrected-per-file.csv + -MoveSessionToConsole + +.EXAMPLE + # TableView lives in Microsoft.UI.Xaml.Controls.Tabular.dll + .\Invoke-WinUIVmCoverage.ps1 -RepoRoot C:\src\microsoft-ui-xaml -VMName MyTestVM ` + -TestFilter '*.TableViewSortingTests.*' -ComponentName TableView -Module Microsoft.UI.Xaml.Controls.Tabular.dll ` + -ComponentPathRegex '\\controls\\dev\\TableView\\' #> [CmdletBinding()] param( @@ -22,13 +31,17 @@ param( [Parameter(Mandatory)][string]$VMName, # One or more runtests filters; each runs with its own collector session, then all are merged. [Parameter(Mandatory)][string[]]$TestFilter, - [Parameter(Mandatory)][string]$OutputDir, + # Work + report folder. Default: \BuildOutput\Coverage\\ (git-ignored). + [string]$OutputDir = '', [string]$ComponentName = 'TitleBar', [string]$ComponentPathRegex = '\\controls\\dev\\TitleBar\\|\\Generated\\TitleBar|\\generated\\Controls\\TitleBar', - [ValidateSet('Microsoft.UI.Xaml.Controls.dll', 'Microsoft.ui.xaml.dll')][string]$Module = 'Microsoft.UI.Xaml.Controls.dll', + [ValidateSet('Microsoft.UI.Xaml.Controls.dll', 'Microsoft.UI.Xaml.Controls.Tabular.dll', 'Microsoft.ui.xaml.dll')] + [string]$Module = 'Microsoft.UI.Xaml.Controls.dll', [string]$Flavor = 'amd64chk', [string]$BaselinePerFileCsv = '', [string]$BaselineLabel = 'Baseline', + # Root of the checkout that produced -BaselinePerFileCsv, if it is not a build-agent root. + [string]$BaselineRepoRoot = '', [PSCredential]$Credential, [switch]$MoveSessionToConsole, [switch]$RefreshPayload, @@ -39,43 +52,74 @@ param( ) $ErrorActionPreference = 'Stop' $scripts = $PSScriptRoot +$RepoRoot = (Resolve-Path $RepoRoot).Path +if (-not $OutputDir) { $OutputDir = Join-Path $RepoRoot "BuildOutput\Coverage\$Flavor\$ComponentName" } New-Item -ItemType Directory -Force $OutputDir | Out-Null $OutputDir = (Resolve-Path $OutputDir).Path $work = Join-Path $OutputDir 'work' $plat = if ($Flavor -match '^(amd64|x86|arm64)') { @{ amd64 = 'x64'; x86 = 'x86'; arm64 = 'arm64' }[$Matches[1]] } else { 'x64' } $cfg = if ($Flavor -match 'fre$') { 'fre' } else { 'chk' } $payloadFlavor = "$plat$cfg" +$payloadDir = Join-Path $RepoRoot "TestPayload\$payloadFlavor" +# Native link project of each module that needs the Step 1 relink (MUX already links with CV/FIXUP). +$relinkProject = @{ 'Microsoft.UI.Xaml.Controls.dll' = 'controls\dev\dll'; 'Microsoft.UI.Xaml.Controls.Tabular.dll' = 'controls\dev\dll-tabular' } $runLabel = Get-Date -Format 'yyyyMMdd-HHmmss' $transcript = Join-Path $OutputDir "coverage-run-$runLabel.log" Start-Transcript $transcript | Out-Null -# Outer try/finally: the transcript is stopped even when Steps 1-3 fail (Step 4 still runs first). +Write-Host "== Output: $OutputDir" +# Outer try/finally: the transcript is stopped whatever happens. try { + $sw = [Diagnostics.Stopwatch]::StartNew() + + # A previous run that was killed before Step 4 leaves instrument-state.json behind: restore first. + if (Test-Path (Join-Path $work 'instrument-state.json')) { + Write-Host "== A previous run did not finish restoring the payload; restoring it first" + & (Join-Path $scripts 'Step4-Restore-Payload.ps1') -RepoRoot $RepoRoot -WorkDir $work -VMName $VMName -Credential $Credential + } + + $runs = @() + $runError = $null + $restoreError = $null try { - $sw = [Diagnostics.Stopwatch]::StartNew() if ($RefreshPayload) { Write-Host "== Refreshing TestPayload\$payloadFlavor from BuildOutput" Push-Location $RepoRoot - try { & .\initrun.ps1 -Flavor $Flavor .\test\CreateTestPayload.ps1 -Platform $plat -Configuration $cfg -Mode DevTestSuite -SkipSymbols -Quiet } - finally { Pop-Location } + try { + & .\initrun.ps1 -Flavor $Flavor .\test\CreateTestPayload.ps1 -Platform $plat -Configuration $cfg -Mode DevTestSuite -SkipSymbols -Quiet + $code = $LASTEXITCODE + } finally { Pop-Location } + if ($code -ne 0) { + throw "CreateTestPayload failed (exit code $code); see $payloadDir\CreateTestPayload.log. Fix the build or payload, then retry." + } + } + $missing = @('runtests.cmd', 'te.exe' | Where-Object { -not (Test-Path (Join-Path $payloadDir $_)) }) + if (-not (Test-Path $payloadDir) -or $missing) { + throw "Test payload $payloadDir is missing or incomplete ($($missing -join ', ')). Build the repo, then rerun with -RefreshPayload." + } + if (-not (Get-ChildItem $payloadDir -Recurse -Filter $Module -File | Select-Object -First 1)) { + throw "No copy of $Module in $payloadDir. Build the repo, then rerun with -RefreshPayload." } # Fail fast, before the long instrumentation, if the payload that will be deployed has no # tests matching a filter (for example because the build predates the component's tests). - # Uses the same selection as runtests.ps1 on the VM. - $payloadDir = Join-Path $RepoRoot "TestPayload\$payloadFlavor" - if (-not $SkipTestCheck -and (Test-Path (Join-Path $payloadDir 'te.exe'))) { + # Mirrors the test files and Desktop selection that runtests.ps1 uses on the VM. + if (-not $SkipTestCheck) { Push-Location $payloadDir # TAEF needs /select:"" passed verbatim; PowerShell 7's default quoting turns the # query into a file name (TAEF then lists every test). $savedArgPassing = $PSNativeCommandArgumentPassing $PSNativeCommandArgumentPassing = 'Legacy' try { - $testDlls = @(Get-ChildItem Test -Filter 'Microsoft.UI.Xaml.Tests.*.dll' -EA SilentlyContinue | ForEach-Object { "Test\$($_.Name)" }) + - @('Test\MUXControls.Test.dll', 'Test\UnpackagedApps\MUXControlsTestApp\MUXControlsTestApp.dll' | Where-Object { Test-Path $_ }) + $testFiles = @(Get-ChildItem Test -Filter 'Microsoft.UI.Xaml.Tests.*.dll' -EA SilentlyContinue | ForEach-Object { "Test\$($_.Name)" }) + + @('Test\MUXControls.Test.dll', 'Test\UnpackagedApps\MUXControlsTestApp\MUXControlsTestApp.dll', 'Test\IXMPTestApp.appx' | Where-Object { Test-Path $_ }) foreach ($f in $TestFilter) { $select = "@Name='*$f' and (@TestPass:IncludeOnlyOn='*Desktop*' OR (NOT(@TestPass:IncludeOnlyOn=*) AND NOT(@TestPass:ExcludeOn='*Desktop*')))" $nameRx = '^' + ([regex]::Escape("*$f") -replace '\\\*', '.*') + '$' - $count = @(& .\te.exe @testDlls /list "/select:`"$select`"" 2>&1 | ForEach-Object { "$_".Trim() } | Where-Object { $_ -match $nameRx }).Count + $listing = @(& .\te.exe @testFiles /list "/select:`"$select`"" 2>&1 | ForEach-Object { "$_".Trim() }) + if ($LASTEXITCODE -ne 0) { + throw "Listing tests with te.exe failed (exit code $LASTEXITCODE) for '$f':`n$(($listing | Where-Object { $_ } | Select-Object -Last 10) -join "`n")" + } + $count = @($listing | Where-Object { $_ -match $nameRx }).Count Write-Host "== Test check: '$f' matches $count test(s) in TestPayload\$payloadFlavor" if (-not $count) { throw "No tests in TestPayload\$payloadFlavor match '$f'. Check the filter, and that the build is current and contains the component's tests (rebuild, then use -RefreshPayload). Use -SkipTestCheck to bypass this check." @@ -86,8 +130,9 @@ try { } Write-Host "== Step 1: instrumentable $Module" - if ($Module -eq 'Microsoft.UI.Xaml.Controls.dll') { - $rl = & (Join-Path $scripts 'Step1-New-InstrumentableDll.ps1') -RepoRoot $RepoRoot -OutputDir (Join-Path $work 'relink') -Flavor $Flavor + if ($relinkProject.ContainsKey($Module)) { + $rl = & (Join-Path $scripts 'Step1-New-InstrumentableDll.ps1') -RepoRoot $RepoRoot -OutputDir (Join-Path $work 'relink') -Flavor $Flavor ` + -ObjSubDir $relinkProject[$Module] -ProjectSubDir $relinkProject[$Module] -ModuleName $Module $srcDll = $rl.dll } else { # MUX already links with /debugtype:cv,fixup,pdata; stage its DLL+PDB side by side. @@ -101,7 +146,6 @@ try { $st = & (Join-Path $scripts 'Step2-Instrument-Payload.ps1') -RepoRoot $RepoRoot -WorkDir $work -SourceDll $srcDll -PayloadFlavor $payloadFlavor -ForceReinstrument:$ForceReinstrument Write-Host "== Step 3: collector + tests on $VMName" - $runs = @() for ($i = 0; $i -lt $TestFilter.Count; $i++) { $label = if ($TestFilter.Count -gt 1) { "$runLabel-$($i + 1)" } else { $runLabel } Write-Host " filter $($i + 1)/$($TestFilter.Count): $($TestFilter[$i])" @@ -109,18 +153,29 @@ try { -Flavor $Flavor -Credential $Credential -RunLabel $label -MoveSessionToConsole:$MoveSessionToConsole } } + catch { $runError = $_; throw } finally { Write-Host "== Step 4: restore payload + VM" try { & (Join-Path $scripts 'Step4-Restore-Payload.ps1') -RepoRoot $RepoRoot -WorkDir $work -VMName $VMName -Credential $Credential } - catch { Write-Warning "RESTORE FAILED - fix manually: $_" } + catch { + $restoreError = $_ + Write-Warning "RESTORE FAILED: $_`n instrument-state.json was kept. Rerun this script (it restores first) or run:`n $scripts\Step4-Restore-Payload.ps1 -RepoRoot '$RepoRoot' -WorkDir '$work' -VMName $VMName" + # The original failure (if any) is rethrown after this block; only note the restore error here. + if ($runError) { Write-Warning "The run itself had already failed: $runError" } + } } + Write-Host "== Step 5: $ComponentName report" $rep = & (Join-Path $scripts 'Step5-Get-ComponentCoverage.ps1') -CoverageFiles @($runs.coverageFile) -OutputDir (Join-Path $OutputDir "report-$runLabel") -RepoRoot $RepoRoot ` -Module ([IO.Path]::GetFileNameWithoutExtension($Module)) -ComponentPathRegex $ComponentPathRegex -ComponentName $ComponentName ` - -BaselinePerFileCsv $BaselinePerFileCsv -BaselineLabel $BaselineLabel -RunLabel "tests '$($TestFilter -join "', '")'" -SkipFunctionCoverage:$SkipFunctionCoverage + -BaselinePerFileCsv $BaselinePerFileCsv -BaselineLabel $BaselineLabel -BaselineRepoRoot $BaselineRepoRoot ` + -RunLabel "tests '$($TestFilter -join "', '")'" -SkipFunctionCoverage:$SkipFunctionCoverage $testSummary = ($runs | ForEach-Object testSummary) -join ' | ' Write-Host ("== DONE in {0:hh\:mm\:ss}: {1} {2}/{3} lines = {4} % | tests: {5}" -f $sw.Elapsed, $ComponentName, $rep.coveredLines, $rep.totalLines, $rep.lineCoveragePct, $testSummary) Write-Host " summary: $($rep.summary)" + if ($restoreError) { + throw "Coverage report written ($($rep.summary)), but restoring the payload/VM FAILED: $restoreError. The payload may still be instrumented; rerun this script (it restores first) or run Step4-Restore-Payload.ps1 -WorkDir '$work'." + } $rep | Add-Member testSummary $testSummary -PassThru | Add-Member coverageFile @($runs.coverageFile) -PassThru } finally { Stop-Transcript | Out-Null } diff --git a/.github/skills/vm-component-coverage/scripts/Step1-New-InstrumentableDll.ps1 b/.github/skills/code-coverage-calculation/scripts/Step1-New-InstrumentableDll.ps1 similarity index 100% rename from .github/skills/vm-component-coverage/scripts/Step1-New-InstrumentableDll.ps1 rename to .github/skills/code-coverage-calculation/scripts/Step1-New-InstrumentableDll.ps1 diff --git a/.github/skills/vm-component-coverage/scripts/Step2-Instrument-Payload.ps1 b/.github/skills/code-coverage-calculation/scripts/Step2-Instrument-Payload.ps1 similarity index 86% rename from .github/skills/vm-component-coverage/scripts/Step2-Instrument-Payload.ps1 rename to .github/skills/code-coverage-calculation/scripts/Step2-Instrument-Payload.ps1 index 9b317f539..a1cf5be14 100644 --- a/.github/skills/vm-component-coverage/scripts/Step2-Instrument-Payload.ps1 +++ b/.github/skills/code-coverage-calculation/scripts/Step2-Instrument-Payload.ps1 @@ -7,7 +7,7 @@ Hyper-V VM: * Instruments ONE copy (VS Microsoft.CodeCoverage.Console.exe, static native instrumentation, - a fresh 16-hex session id) - this is slow for a 38 MB chk DLL (~10-20 min), so the result is + a fresh 16-hex session id) - this is slow for a 38 MB chk DLL (~20-50 min), so the result is CACHED in \instrumented keyed by the SHA256 of the input DLL and reused next time (together with the session id that is baked into it). The cache is only reused while the coverage tool that instrumented it still exists at the same file version, because the @@ -20,10 +20,13 @@ whenever that copy is not instrumented, so a rebuilt payload is never restored to an older build's DLL; an existing backup is kept only while the copy is still instrumented. * Writes \instrument-state.json (session id, copies, backups, runtimes added) used by - the run and restore steps. + the run and restore steps. It is written after every backup is taken but BEFORE the first + payload file is overwritten ("complete": false until all copies are done), so an interrupted + run can always be restored by Step4-Restore-Payload.ps1 (the orchestrator does that + automatically at the start of the next run). - Never edits repo source. Only TestPayload\ (a generated folder) is modified, and - Step4-Restore-Payload.ps1 restores it byte-for-byte. + Never edits repo source or BuildOutput. Only TestPayload\ (a generated, git-ignored + folder) is modified, and Step4-Restore-Payload.ps1 restores it byte-for-byte. #> [CmdletBinding()] param( @@ -120,6 +123,8 @@ $copies = @(Get-ChildItem $payload -Recurse -Filter $moduleName -File) if (-not $copies) { throw "No copies of $moduleName under $payload" } $instrHash = (Get-FileHash $instrDll).Hash $knownInstrumented = @($instrHash, $(if ($state) { $state.instrumentedHash })) | Where-Object { $_ } + +# Pass 1 (no destructive writes): back up every copy and work out which runtimes will be added. $entries = @() $runtimesAdded = @() foreach ($c in $copies) { @@ -134,24 +139,33 @@ foreach ($c in $copies) { New-Item -ItemType Directory -Force (Split-Path $bak) | Out-Null Copy-Item $c.FullName $bak -Force } elseif (-not (Test-Path $bak)) { - throw "$rel is already instrumented but has no backup; recreate the payload." + throw "$rel is already instrumented but has no backup; recreate the payload (-RefreshPayload)." } - Copy-Item $instrDll $c.FullName -Force foreach ($rt in $runtimes) { $rtDest = Join-Path $c.DirectoryName $rt.Name if (-not (Test-Path $rtDest)) { $runtimesAdded += $rtDest.Substring($payload.Length + 1) } - Copy-Item $rt.FullName $rtDest -Force } $entries += [pscustomobject]@{ relativePath = $rel; backup = $bak; originalHash = (Get-FileHash $bak).Hash } - Info " instrumented copy -> $rel" } if ($state -and $state.runtimesAdded) { $runtimesAdded = @($state.runtimesAdded) + $runtimesAdded | Select-Object -Unique } +# Persist the recovery state BEFORE the first overwrite, so Step 4 can always restore, even if +# a copy below fails or the process is killed part-way ("complete" stays false until the end). $newState = [pscustomobject]@{ module = $moduleName; moduleStem = $stem; sessionId = $sessionId; payloadDir = $payload instrumentedDll = $instrDll; instrumentedHash = $instrHash; coverageTool = $CoverageConsole; toolVersion = $toolVersion - copies = $entries; runtimesAdded = @($runtimesAdded); instrumentedAt = (Get-Date).ToString('o') + copies = $entries; runtimesAdded = @($runtimesAdded); complete = $false; instrumentedAt = (Get-Date).ToString('o') } $newState | ConvertTo-Json -Depth 5 | Set-Content $statePath -Encoding UTF8 + +# Pass 2: overwrite every copy and add the coverage runtimes next to it. +foreach ($e in $entries) { + $dest = Join-Path $payload $e.relativePath + Copy-Item $instrDll $dest -Force + foreach ($rt in $runtimes) { Copy-Item $rt.FullName (Join-Path (Split-Path $dest) $rt.Name) -Force } + Info " instrumented copy -> $($e.relativePath)" +} +$newState.complete = $true +$newState | ConvertTo-Json -Depth 5 | Set-Content $statePath -Encoding UTF8 Info "payload instrumented: $($entries.Count) copies, session $sessionId (state: $statePath)" $newState diff --git a/.github/skills/vm-component-coverage/scripts/Step3-Invoke-VmCoverageRun.ps1 b/.github/skills/code-coverage-calculation/scripts/Step3-Invoke-VmCoverageRun.ps1 similarity index 98% rename from .github/skills/vm-component-coverage/scripts/Step3-Invoke-VmCoverageRun.ps1 rename to .github/skills/code-coverage-calculation/scripts/Step3-Invoke-VmCoverageRun.ps1 index 8dcd4d179..bff212b36 100644 --- a/.github/skills/vm-component-coverage/scripts/Step3-Invoke-VmCoverageRun.ps1 +++ b/.github/skills/code-coverage-calculation/scripts/Step3-Invoke-VmCoverageRun.ps1 @@ -11,8 +11,10 @@ as the logged-in VM user (interactive scheduled task - it must outlive the PS Direct session). 3. Wait for \\.\pipe\CodeCoverage.pipe., then give it a NULL DACL (lib\Set-CoveragePipeAcl.ps1) so low-integrity test processes can connect. - 4. Run the tests with the repo's own tools\run-tests-on-vm.ps1 -SkipPayload (deploys the - instrumented payload incrementally and runs runtests.cmd on the interactive desktop). + 4. Run the tests with the repo's own tools\run-tests-on-vm.ps1 -SkipPayload. -SkipPayload only + skips regenerating TestPayload from BuildOutput (which would overwrite the instrumented + DLLs); the runner still copies the changed payload files to the VM and runs runtests.cmd + on the interactive desktop. 5. "Microsoft.CodeCoverage.Console.exe shutdown " -> collector finalizes the .coverage. 6. Copy the .coverage + collector logs back to \raw (also when the run fails), and always close the PowerShell Direct session. diff --git a/.github/skills/vm-component-coverage/scripts/Step4-Restore-Payload.ps1 b/.github/skills/code-coverage-calculation/scripts/Step4-Restore-Payload.ps1 similarity index 91% rename from .github/skills/vm-component-coverage/scripts/Step4-Restore-Payload.ps1 rename to .github/skills/code-coverage-calculation/scripts/Step4-Restore-Payload.ps1 index 1e66e6237..e0308135f 100644 --- a/.github/skills/vm-component-coverage/scripts/Step4-Restore-Payload.ps1 +++ b/.github/skills/code-coverage-calculation/scripts/Step4-Restore-Payload.ps1 @@ -4,7 +4,9 @@ .DESCRIPTION Uses \instrument-state.json written by Step 2: - * copies every backed-up original back over its payload copy (hash-verified), + * copies every backed-up original back over its payload copy (hash-verified; copies that + already match their original, e.g. because Step 2 was interrupted before overwriting them, + are left alone), * deletes the static_covrun*.dll files that Step 2 added, * optionally (-VMName) does the same inside the VM's deployed payload (C:\TestPayload\-, the path tools\run-tests-on-vm.ps1 uses) and @@ -31,6 +33,8 @@ $payload = $state.payloadDir foreach ($c in $state.copies) { $dest = Join-Path $payload $c.relativePath + # Tolerate copies that were never overwritten (e.g. Step 2 was interrupted part-way). + if ((Test-Path $dest) -and (Get-FileHash $dest).Hash -eq $c.originalHash) { Info "unchanged $($c.relativePath)"; continue } Copy-Item $c.backup $dest -Force if ((Get-FileHash $dest).Hash -ne $c.originalHash) { throw "restore hash mismatch: $dest" } Info "restored $($c.relativePath)" diff --git a/.github/skills/vm-component-coverage/scripts/Step5-Get-ComponentCoverage.ps1 b/.github/skills/code-coverage-calculation/scripts/Step5-Get-ComponentCoverage.ps1 similarity index 83% rename from .github/skills/vm-component-coverage/scripts/Step5-Get-ComponentCoverage.ps1 rename to .github/skills/code-coverage-calculation/scripts/Step5-Get-ComponentCoverage.ps1 index 019464a5c..938cb4510 100644 --- a/.github/skills/vm-component-coverage/scripts/Step5-Get-ComponentCoverage.ps1 +++ b/.github/skills/code-coverage-calculation/scripts/Step5-Get-ComponentCoverage.ps1 @@ -29,6 +29,9 @@ param( [string]$RunLabel = 'This run', # Root of the checkout that produced the coverage; used to make its paths repo-relative. [string]$RepoRoot = '', + # Root of the checkout (or build agent) that produced -BaselinePerFileCsv, when it is not a + # standard build-agent root such as C:\__w\1\s\ (e.g. D:\src\other-checkout). + [string]$BaselineRepoRoot = '', [switch]$SkipFunctionCoverage ) $ErrorActionPreference = 'Stop' @@ -54,15 +57,19 @@ if (-not $modDir) { throw "Module $Module not found in coverage (modules: $((Get $modCsv = Join-Path $modDir.FullName 'corrected-per-file.csv' $modSummary = Get-Content (Join-Path $modDir.FullName 'corrected-summary.json') -Raw | ConvertFrom-Json -$rootKey = if ($RepoRoot) { ($RepoRoot.ToLowerInvariant() -replace '/', '\').TrimEnd('\') + '\' } else { '' } +function Get-RootKey([string]$r) { if ($r) { ($r.ToLowerInvariant() -replace '/', '\').TrimEnd('\') + '\' } else { '' } } +$rootKeys = @((Get-RootKey $RepoRoot), (Get-RootKey $BaselineRepoRoot)) | Where-Object { $_ } function Get-RepoKey([string]$p) { - # repo-relative key: strip the checkout root (this run), else a build-agent root (e.g. a CI - # baseline: C:\__w\1\s\), else everything up to the first top-level repo folder; then - # normalize build flavor dirs. + # repo-relative key: strip a known checkout root (this run's -RepoRoot or -BaselineRepoRoot), + # else a build-agent root (e.g. a CI baseline: C:\__w\1\s\), else everything up to the first + # distinctive top-level repo folder (controls, dxaml, BuildOutput) and only then the generic + # ones (src, test); then normalize build flavor dirs. $k = $p.ToLowerInvariant() -replace '/', '\' - if ($rootKey -and $k.StartsWith($rootKey)) { $k = $k.Substring($rootKey.Length) } + $root = $rootKeys | Where-Object { $k.StartsWith($_) } | Select-Object -First 1 + if ($root) { $k = $k.Substring($root.Length) } elseif ($k -match '^[a-z]:\\+(__w|a)\\+\d+\\+s\\+') { $k = $k.Substring($Matches[0].Length) } - else { $k = $k -replace '^.*?\\(?=(controls|dxaml|buildoutput|src|test)\\)', '' } + elseif ($k -match '\\(controls|dxaml|buildoutput)\\') { $k = $k -replace '^.*?\\(?=(controls|dxaml|buildoutput)\\)', '' } + else { $k = $k -replace '^.*?\\(?=(src|test)\\)', '' } $k -replace '\\obj\\(x86|amd64|arm64)(chk|fre)\\', '\obj\\' } @@ -86,6 +93,16 @@ $comp = foreach ($r in $rows) { } } $comp = $comp | Sort-Object File +$baselineWarning = '' +if ($BaselinePerFileCsv) { + $matched = @($comp | Where-Object { $null -ne $_.BaselineTotal }).Count + if ($matched -eq 0) { + $baselineWarning = "The baseline matched none of the $(@($comp).Count) component files, so every baseline value is n/a. If the baseline came from a different checkout, pass its root with -BaselineRepoRoot." + } elseif ($matched -lt @($comp).Count / 2) { + $baselineWarning = "The baseline matched only $matched of $(@($comp).Count) component files. If it came from a different checkout, pass its root with -BaselineRepoRoot." + } + if ($baselineWarning) { Write-Warning $baselineWarning } +} $comp | Export-Csv (Join-Path $OutputDir 'component-per-file.csv') -NoTypeInformation $tot = ($comp | Measure-Object TotalLines -Sum).Sum $cov = ($comp | Measure-Object CoveredLines -Sum).Sum @@ -129,6 +146,7 @@ if ($BaselinePerFileCsv) { $bt = ($comp | Where-Object { $null -ne $_.BaselineTotal } | Measure-Object BaselineTotal -Sum).Sum $bc = ($comp | Where-Object { $null -ne $_.BaselineTotal } | Measure-Object BaselineCovered -Sum).Sum if ($bt) { [void]$md.AppendLine("`n$BaselineLabel for the same files: $bc / $bt = $([math]::Round(100.0*$bc/$bt,2)) %") } + if ($baselineWarning) { [void]$md.AppendLine("`n> **Warning:** $baselineWarning") } } [void]$md.AppendLine() [void]$md.AppendLine("| File | Covered / Total | $RunLabel % | $BaselineLabel % |") @@ -159,4 +177,5 @@ if ($funcRows) { $md.ToString() | Set-Content (Join-Path $OutputDir 'coverage-summary.md') -Encoding UTF8 Info "$ComponentName : $cov / $tot = $pct % -> $(Join-Path $OutputDir 'coverage-summary.md')" [pscustomobject]@{ component = $ComponentName; coveredLines = $cov; totalLines = $tot; lineCoveragePct = $pct - summary = (Join-Path $OutputDir 'coverage-summary.md'); perFile = (Join-Path $OutputDir 'component-per-file.csv') } + summary = (Join-Path $OutputDir 'coverage-summary.md'); perFile = (Join-Path $OutputDir 'component-per-file.csv') + baselineWarning = $baselineWarning } diff --git a/.github/skills/vm-component-coverage/scripts/lib/Build-CoverageTreeReport.ps1 b/.github/skills/code-coverage-calculation/scripts/lib/Build-CoverageTreeReport.ps1 similarity index 100% rename from .github/skills/vm-component-coverage/scripts/lib/Build-CoverageTreeReport.ps1 rename to .github/skills/code-coverage-calculation/scripts/lib/Build-CoverageTreeReport.ps1 diff --git a/.github/skills/vm-component-coverage/scripts/lib/Build-FunctionCoverageTreeReport.ps1 b/.github/skills/code-coverage-calculation/scripts/lib/Build-FunctionCoverageTreeReport.ps1 similarity index 100% rename from .github/skills/vm-component-coverage/scripts/lib/Build-FunctionCoverageTreeReport.ps1 rename to .github/skills/code-coverage-calculation/scripts/lib/Build-FunctionCoverageTreeReport.ps1 diff --git a/.github/skills/vm-component-coverage/scripts/lib/Get-FunctionCoverage.ps1 b/.github/skills/code-coverage-calculation/scripts/lib/Get-FunctionCoverage.ps1 similarity index 100% rename from .github/skills/vm-component-coverage/scripts/lib/Get-FunctionCoverage.ps1 rename to .github/skills/code-coverage-calculation/scripts/lib/Get-FunctionCoverage.ps1 diff --git a/.github/skills/vm-component-coverage/scripts/lib/Repair-CoverageDuplicates.ps1 b/.github/skills/code-coverage-calculation/scripts/lib/Repair-CoverageDuplicates.ps1 similarity index 100% rename from .github/skills/vm-component-coverage/scripts/lib/Repair-CoverageDuplicates.ps1 rename to .github/skills/code-coverage-calculation/scripts/lib/Repair-CoverageDuplicates.ps1 diff --git a/.github/skills/vm-component-coverage/scripts/lib/Set-CoveragePipeAcl.ps1 b/.github/skills/code-coverage-calculation/scripts/lib/Set-CoveragePipeAcl.ps1 similarity index 100% rename from .github/skills/vm-component-coverage/scripts/lib/Set-CoveragePipeAcl.ps1 rename to .github/skills/code-coverage-calculation/scripts/lib/Set-CoveragePipeAcl.ps1 diff --git a/.github/skills/vm-component-coverage/scripts/lib/Split-CoverageByModule.ps1 b/.github/skills/code-coverage-calculation/scripts/lib/Split-CoverageByModule.ps1 similarity index 100% rename from .github/skills/vm-component-coverage/scripts/lib/Split-CoverageByModule.ps1 rename to .github/skills/code-coverage-calculation/scripts/lib/Split-CoverageByModule.ps1 diff --git a/.github/skills/vm-component-coverage/scripts/Set-VmCredential.ps1 b/tools/Set-VmCredential.ps1 similarity index 72% rename from .github/skills/vm-component-coverage/scripts/Set-VmCredential.ps1 rename to tools/Set-VmCredential.ps1 index 93376d206..3874c7edf 100644 --- a/.github/skills/vm-component-coverage/scripts/Set-VmCredential.ps1 +++ b/tools/Set-VmCredential.ps1 @@ -1,23 +1,28 @@ <# .SYNOPSIS - Gate for the coverage skill: validate the VM and cache its credential WITHOUT the password ever - passing through the agent/chat. + Validate a Hyper-V test VM and cache its credential for the VM test tools, WITHOUT the password + ever passing through a chat or agent session. .DESCRIPTION + Shared by tools\run-tests-on-vm.ps1 users and the repo skills that run tests on a VM (for + example .github\skills\code-coverage-calculation). * Fails unless the Hyper-V VM exists and is Running. * Prompts for the password in a SEPARATE console window (username pre-filled), the same way tools\run-tests-on-vm.ps1 does, and writes the DPAPI-encrypted cache - %USERPROFILE%\.winui-test\vmcred-.xml - the cache that run-tests-on-vm.ps1 and the - Step3/Step4 scripts already read. (Step3 does not forward -Credential to run-tests-on-vm.ps1, - so the cache must exist; passing -Credential alone is not enough.) - * Validates the login over PowerShell Direct and that the user has an Active desktop session. - * Only replaces an existing cache after the new credential has been validated. + %USERPROFILE%\.winui-test\vmcred-.xml - the cache that run-tests-on-vm.ps1 reads. + * Validates the login over PowerShell Direct and that the user has an Active desktop session + (UI tests need one). + * A newly entered credential replaces the existing cache only after ALL of these checks pass; + otherwise the previous cache is left untouched. + +.EXAMPLE + .\tools\Set-VmCredential.ps1 -VMName MyTestVM -UserName TestUser #> [CmdletBinding()] param( [Parameter(Mandatory)][string]$VMName, - # Plain account name exactly as qwinsta shows it on the VM (no ".\" or "VM\" prefix): - # Step3 matches the session row with the credential's UserName. + # Plain account name exactly as qwinsta shows it on the VM (no ".\" or "VM\" prefix): the + # active-session check matches the session row by this name. [Parameter(Mandatory)][string]$UserName, # Re-prompt even if a cached credential for this VM/user exists. [switch]$Force @@ -66,10 +71,15 @@ try { } -ArgumentList $UserName } finally { Remove-PSSession $s } +if ("$row" -notmatch '\sActive') { + # Keep the previous cache: a valid password for an account that is not logged on to the + # desktop must not replace a working credential. + Remove-Item $newFile -ErrorAction SilentlyContinue + throw "'$UserName' has no Active desktop session on '$VMName' (session: '$("$row".Trim())'). Log on to the VM desktop as that user, then retry." +} + if (Test-Path $newFile) { Move-Item $newFile $credFile -Force } -$active = "$row" -match '\sActive' [pscustomobject]@{ VMName = $VMName; UserName = $UserName; CredentialCache = $credFile - LogonOk = $true; DesktopSession = "$row".Trim(); DesktopActive = $active + LogonOk = $true; DesktopSession = "$row".Trim(); DesktopActive = $true } -if (-not $active) { throw "'$UserName' has no Active desktop session on '$VMName'. Log on to the VM desktop, then retry." } From b13c210ed843cce8b27f2f60d1672b717ef3480f Mon Sep 17 00:00:00 2001 From: ayushjai19 <244442986+ayushjai19@users.noreply.github.com> Date: Tue, 20 Oct 2026 19:48:11 +0530 Subject: [PATCH 09/10] NavigationView: keep the #7609 force-close guard to first-time setup, not re-templating m_appliedTemplate is also false while a template is re-applied, and UpdateIsClosedCompact can run an app's custom GoToStateCore in that window. Use a never-reset m_templateAppliedOnce so a PaneDisplayMode change during re-templating still clears m_wasForceClosed, as on main. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: ec9a38b2-2629-4a10-bfb5-552decfe6848 --- controls/dev/NavigationView/NavigationView.cpp | 7 ++++--- controls/dev/NavigationView/NavigationView.h | 2 ++ 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/controls/dev/NavigationView/NavigationView.cpp b/controls/dev/NavigationView/NavigationView.cpp index 7c5ba191e..60cfc46cf 100644 --- a/controls/dev/NavigationView/NavigationView.cpp +++ b/controls/dev/NavigationView/NavigationView.cpp @@ -696,6 +696,7 @@ void NavigationView::OnApplyTemplate() m_shadowCasterEaseOutStoryboard.set(GetTemplateChildT(c_shadowCasterEaseOutStoryboard, controlProtected)); m_appliedTemplate = true; + m_templateAppliedOnce = true; // Do initial setup UpdatePaneDisplayMode(); @@ -4206,9 +4207,9 @@ void NavigationView::OnPropertyChanged(const winrt::DependencyPropertyChangedEve { // m_wasForceClosed is set to true because ToggleButton is clicked and Pane is closed. // When PaneDisplayMode is changed, reset the force flag to make the Pane can be opened automatically again. - // Before the template is applied, the flag can only have come from the app setting IsPaneOpen=false - // (e.g. in XAML ahead of PaneDisplayMode), so keep it to honor that initial state. - if (m_appliedTemplate) + // Before the template is first applied, the flag can only have come from the app setting IsPaneOpen=false + // (e.g. in XAML ahead of PaneDisplayMode), so keep it to honor that initial state. Re-templating still resets it. + if (m_templateAppliedOnce) { m_wasForceClosed = false; } diff --git a/controls/dev/NavigationView/NavigationView.h b/controls/dev/NavigationView/NavigationView.h index d0f29b196..43440ef71 100644 --- a/controls/dev/NavigationView/NavigationView.h +++ b/controls/dev/NavigationView/NavigationView.h @@ -482,6 +482,8 @@ private: NavigationRecommendedTransitionDirection m_pendingSelectionChangedDirection{ NavigationRecommendedTransitionDirection::Default }; bool m_appliedTemplate{ false }; + // Unlike m_appliedTemplate, this is never reset, so it tells first-time setup apart from re-templating. + bool m_templateAppliedOnce{ false }; // Identifies whenever a call is the result of OnApplyTemplate bool m_fromOnApplyTemplate{ false }; From d5132fd2e407204c86bceec3600e74c4e1a65138 Mon Sep 17 00:00:00 2001 From: Hitesh Kumar Date: Fri, 23 Oct 2026 14:10:17 +0530 Subject: [PATCH 10/10] TableView: templated column headers, UIA resize, sort fixes, recycle perf, theming (#12307) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Fixes | Internal issue | Type | Change | |---|---|---| | https://task.ms/64475625 | Bug | Column headers are a templated, restylable `TableViewColumnHeader` with visual states | | https://task.ms/64475623 | Bug | Header typography comes from the header Style, not C++ local values | | https://task.ms/64143451 | Bug | Header column separators are always drawn (no new property); hide them by overriding `TabularSurfaceHeaderSeparatorBrush` | | https://task.ms/63996584 | Task | Column headers expose the UIA Transform pattern so UIA clients can resize columns | | https://task.ms/64475611 | Bug | Outer border uses new `TabularSurfaceOuterBorderBrush` (`CardStrokeColorDefault`); grid-line values were in #12349 | | https://task.ms/64475629 | Bug | Default group header uses theme tokens (SemiBold key style, 8px spacing); the item count inherits the header `Foreground` | | https://task.ms/64652961 | Task | Row recycling no longer regenerates cells or refreshes row chrome twice (scroll performance) | | https://task.ms/63790855 | Task | Sort: `CanSort` is enforced and `SortByColumn(column, None)` always clears; a sort through a source another table owns returns false; no spurious `Sorted`; an app-owned sort survives a `CanSort`/`CanUserSortColumns` opt-out | | https://task.ms/63787021 | Task | Cell automation-peer constructor validates its arguments; `BeginEdit` rolls back when it throws | Not changed: https://task.ms/64512732 (by design: Auto columns follow content width), https://task.ms/64475627 (design decision: keep chevron sort glyphs), https://task.ms/64513223 (investigation only; fix deferred). ## PR Type - [x] Bugfix - [x] Feature - [ ] Code style update (formatting, renaming) - [ ] Refactoring (no functional changes, no API changes) - [ ] Build related changes - [x] Documentation content changes - [ ] Other (please describe): ## Description Follow-up to #12349 (merged) and #12285. Column headers move from code-built Grids to a new templated `TableViewColumnHeader`, restylable through `TableView.ColumnHeaderStyle` or `TableViewColumn.HeaderStyle`. Header peers gain the UIA Transform pattern for column resize. Sort behavior is tightened around `CanSort`, opt-outs and shared `TableViewSource` ownership. Row recycling reuses cells. Outer border, header lines and the default group header follow Fluent tokens. Review-driven fixes included here: - The header Style's `TargetType` `TypeName` is kept alive while it is compared; a Style with only `BasedOn` typed is applied, and its setters are read before applying it seals the Style. Incompatible, untyped or cyclic `BasedOn` Styles are ignored with a debug diagnostic. - A header disabled by its Style reports `IsEnabled=false` and is not invokable through UIA. - UIA `Transform.Resize` converts physical pixels through the table's full visual scale (not only `RasterizationScale`), and a width change below the drag jitter deadband is written once, exactly. - Disabling a header in its High Contrast hover/pressed state keeps the disabled foreground (state order fixed); the disabled sort glyph follows the text brush across theme changes. - A `CanSort` opt-out made inside `BeginningEdit` is replayed after the edit it let open closes, and rebuilds headers even when a `Sorted` handler throws. - Header rebuilds caused by `Content`/`Header` or `Columns` changes keep focus in the header band, respect an app's own focus move, and release `UIElement` headers back to the app. - A disabled group header does not dim its item count twice. The count opacity follows `ContentTemplate` changes while the header is disabled.
Product changes by file | File | +/- | What changed | Items | |---|---|---|---| | `TableView/TableView.cpp` (+ .h) | +684 / −382 | `RebuildHeaders` creates `TableViewColumnHeader` controls; applies `ColumnHeaderStyle`/`HeaderStyle` with TargetType/BasedOn checks; density MinHeight/Padding only when the Style doesn't set them
`ReleaseHeaderCells` and header-focus handling for removed/replaced columns, re-entrant rebuilds and app focus moves
Header bottom rule thickness set on `PART_HeaderRow`; header lines no longer follow `GridLinesVisibility`
Row prepare/clear refresh chrome once; `TryResizeColumnFromAutomation` writes small widths exactly | 64475625, 64475623, 64143451, 64652961, 63996584, 63790855 | | `TableView/TableViewColumnHeader.cpp` (+ .h; replaces `TableViewHeaderCell.h`) | +435 / −210 | New sealed templated `ContentControl` with read-only `Column`
Resolves `PART_ContentPresenter`, `PART_GridLine`, `PART_SortIndicatorHost`, `PART_ResizeGripperHost`; hosts the sort glyph and gripper
Drives `CommonStates`/`HighContrastStates`/`SortStates`; disabled sort glyph bound to the text brush | 64475625, 64475623 | | `TableView/TableViewRow.cpp` (+ .h) | +191 / −35 | Recycled rows keep their cells unless cell automation peers exist; chrome refreshed once; tooltips re-applied on recycle
`BeginCellEdit` restores display element, edit state and focus on throw | 64652961, 63787021 | | `TableView/TableView.xaml` | +128 / −18 | Default `TableViewColumnHeader` Style/template: typography setters, visual states, parts
Outer border uses `TabularSurfaceOuterBorderBrush`; header lines use `TabularSurfaceHeaderSeparatorBrush`
Group header: `TableViewGroupExpanderSpacing` (8), padding 8,8,12,8, key/count text styles, count inherits `Foreground` | 64475625, 64475623, 64475611, 64475629 | | `TableView/TableViewColumnHeaderAutomationPeer.cpp` (+ .h) | +127 / −9 | `ITransformProvider`: `Resize` converts physical px through the visual scale; `CanResize` tracks gripper availability (pinned → false)
`IsEnabled` follows the header; name read from the content presenter | 63996584, 64475625 | | `TableView/TableView_Sort.cpp` | +113 / −39 | `SortByColumn(None)` bypasses the `CanSort` gate; other sorts refused
Sort through a source owned by another table returns false; implicit clears keep the app's sort and raise no `Sorted`; app-lit columns left alone on opt-out | 63790855 | | `TableView/TableView_Layout.cpp` | +74 / −14 | Realized-row walk only when a visible Auto column exists
Transform `CanResize` changes raised for existing header peers | 64652961, 63996584 | | `TableView/TableView_Editing.cpp` | +63 / −15 | `BeginEdit` unwinds on throw; `CanSort` opt-out during `BeginningEdit` queued until the edit closes | 63787021, 63790855 | | `TableView/TableViewCellAutomationPeer.cpp` (+ .h, `TableViewCell.h`) | +48 / −0 | Public constructor throws `E_INVALIDARG` for null args, negative/out-of-range index or a foreign column; internal path uses `TrustedOwnerTag` | 63787021 | | `TableView/TableViewGroupHeader.cpp` (+ .h) | +39 / −0 | Default count's opacity is not applied twice when disabled | 64475629 | | `TableView/TableView.idl` | +31 / −13 | `TableViewColumnHeader`, `TableView.ColumnHeaderStyle`, `TableViewColumn.HeaderStyle`
`CanSort`/`SortByColumn`/`ToggleSortDirection`/`GridLinesVisibility` docs; cell-peer `E_INVALIDARG`; group-header parts | 64475625, 63790855, 64143451, 63787021 | | `TableView/TableView_Columns.cpp` | +19 / −0 | Releases removed columns' header cells; old `UIElement` `Header` returned to the app on change | 64475625 | | `TableView/TableViewColumn.cpp` | +7 / −0 | `HeaderStyle` change queues a header rebuild | 64475625 | | `TableView/TableViewSource.h` | +6 / −0 | `IsOwnedByOther` helper for sort sync | 63790855 | | `CommonStyles/TabularSurfaces_themeresources.xaml` | +5 / −2 | New `TabularSurfaceOuterBorderBrush` (Dark/Light/HC); group count opacity documented as secondary-over-primary text (0.77 Dark) | 64475611, 64475629 | | `TableView/TableView.vcxitems` | +2 / −1 | Swap `TableViewHeaderCell.h` for `TableViewColumnHeader.*` | 64475625 | | Generated / metadata (`Generated/TableView.properties.*`, `Generated/TableViewColumn.properties.*`, `dll-tabular/XamlMetadataProviderGenerated.h`) | +123 / −0 | Codegen for the new properties and type | 64475625 | **Total: 17 rows, 29 product files, +2117 / −738.** Also: 19 test files (+3718 / −32), 3 docs (+161 / −35), 6 sample files (+2 / −9).
### New preview API (needs API review) - **`TableViewColumnHeader`**: sealed, templated `ContentControl`, created only by `TableView`. - Read-only `Column` property. - Template parts (optional): `PART_ContentPresenter`, `PART_GridLine`, `PART_SortIndicatorHost`, `PART_ResizeGripperHost`. - Visual state groups: `CommonStates`, `HighContrastStates`, `SortStates`. - **`TableView.ColumnHeaderStyle`**: Style applied to every column header. - **`TableViewColumn.HeaderStyle`**: per-column Style; wins over `TableView.ColumnHeaderStyle`. There is no `HeaderGridLinesVisibility` property. `GridLinesVisibility` affects body lines only; header lines are always drawn. To hide them, override `TabularSurfaceHeaderSeparatorBrush` with `Transparent`. ### Current Behavior - Headers are built in C++ with no template, no visual states and forced local typography, so apps can't restyle them. Header separators don't show. - Header peers report `HeaderItem` without Transform, so UIA clients can't resize columns. - `SortByColumn` ignores `CanSort`; `CanSort=false` on a sorted column leaves the sort in place; sorting through a source another table owns reports success; some implicit clears raise `Sorted(None)` while the app's sort remains. - The cell peer constructor accepts invalid arguments; a throw in `BeginEdit` leaves edit state half-open. - Recycling a row rebuilt every cell, refreshed chrome up to twice and invalidated the table's measure. - The outer border has no dedicated brush; the default group header uses raw weights and off-grid spacing. ### New Behavior - Headers use the templated `TableViewColumnHeader` (see API above); typography, padding and brushes come from Style setters. - Header peers implement `ITransformProvider` through the existing resize path (Min/Max clamping, scale conversion; pinned columns report `CanResize=false`). - `CanSort=false` blocks header clicks, `SortByColumn` (except `None`) and `ToggleSortDirection`, and clears the column's own active sort; sorts through a source owned by another table return false; `Sorted` is raised only when the table's sort state changes. - Cell peer constructor throws `E_INVALIDARG` on bad input; `BeginEdit` rolls back on failure. - Recycled rows reuse their cells unless cell automation peers exist; chrome refreshes once. > **Visible changes** > - Outer border uses `CardStrokeColorDefault`. > - Header lines are always shown, even when `GridLinesVisibility` is `None` or `Horizontal`. > - Group header restyled: 8px spacing; the count follows the header `Foreground`. > - Disabled header text and sort glyph dim. > - TableView samples no longer opt out of High Contrast backplates app-wide. ## Customer Impact Restylable column headers, visible header separators, Fluent-aligned outer border and group headers, UIA column resize for assistive technology, smoother scrolling and predictable sort events. ## Regression Potential - [ ] Low risk — isolated change, limited scope - [x] Medium risk — touches shared components or public APIs - [ ] High risk — architectural or breaking API change Header construction (focus restore, frozen columns, UIA peer, tooltips, resize, sort glyph) moved to a templated control. Sort event semantics are tighter: `CanSort` is enforced on programmatic sorts and fewer `Sorted` events fire. Outer border color and always-on header lines change visibly. Covered by new and existing tests. ## How Has This Been Tested? - [x] I have performed a self-review of my own code - [x] I have added tests to cover my changes - [x] Existing tests pass locally Full TableView API and interaction suites on test VMs: 877/882 pass at 224d10d5c; 3 Localization tests are [Ignore] and 2 intermittent failures passed 3/3 on rerun. Product, test apps and TableView sample apps build (x64 chk). Also covered: `VerifyDisabledGroupHeaderCountFollowsContentTemplateChanges`. Key new tests: - `VerifyColumnHeaderTemplateParts`, `VerifyColumnHeaderSortStates`, `VerifyColumnHeaderTypographyHasNoLocalValues` - `VerifyColumnHeaderStyleApplies`, `VerifyBasedOnOnlyColumnHeaderStyleApplies`, `VerifyIncompatibleColumnHeaderStyleIsIgnored` - `VerifyHeaderGridLinesShowWhateverTheBodyGridLines`, `VerifyTransparentHeaderSeparatorBrushHidesHeaderLines`, `VerifyGridLineAndOuterBorderBrushesMatchFluentStrokeTokens` - `VerifyDisablingHighContrastHoveredHeaderKeepsDisabledForeground`, `VerifyDefaultGroupHeaderCountInheritsForegroundThatFollowsStates` - `VerifyTransformResizeAccountsForAncestorScale`, `VerifyTransformResizeSmallWidthChangeWritesOnce`, `VerifyHeaderDisabledByStyleIsNotEnabledOrInvokable` - `VerifySortByColumnRespectsCanSort`, `VerifyImplicitClearKeepsSourceSort`, `VerifyUngatingKeepsAppLitColumnWithoutEvents`, `VerifySortOptOutDuringBeginningEditKeepsTheEditOpen` - `VerifyRecycledRowWithoutCellPeersReusesItsCells`, `VerifyHeaderFocusStaysInHeaderBandWhenColumnsReplaced`, `VerifyBeginEditThatThrowsRollsBackAndAllowsALaterEdit`, `VerifyCellPeerConstructorValidatesArguments` --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 94961bb8-d187-483c-bece-19e203752c67 --- .../TableViewAppCppPackaged/App.xaml.cpp | 2 - .../TableViewAppCppUnpackaged/App.xaml.cpp | 2 - .../TableViewAppCsPackaged/App.xaml.cs | 1 - .../TableViewAppCsUnpackaged/App.xaml.cs | 1 - Samples/TableViewSampleApp/App.xaml.cs | 1 - .../Controls/SamplePresenter.xaml | 4 +- .../TabularSurfaces_themeresources.xaml | 7 +- .../dev/Generated/TableView.properties.cpp | 34 + controls/dev/Generated/TableView.properties.h | 9 + .../Generated/TableViewColumn.properties.cpp | 34 + .../Generated/TableViewColumn.properties.h | 9 + .../APITests/TableViewSource_APITests.cs | 60 + .../TableView_AutomationPeer_APITests.cs | 448 +++++ .../APITests/TableView_Columns_APITests.cs | 116 +- .../APITests/TableView_Editing_APITests.cs | 292 +++ .../APITests/TableView_Hierarchy_APITests.cs | 28 + .../TableView_NegativePath_APITests.cs | 1 + .../APITests/TableView_Recycling_APITests.cs | 215 +++ .../APITests/TableView_Sizing_APITests.cs | 54 +- .../APITests/TableView_Sorting_APITests.cs | 530 ++++++ .../TableView_SourceOwnership_APITests.cs | 130 ++ .../APITests/TableView_Theming_APITests.cs | 1633 ++++++++++++++++- .../APITests/TableView_ToolTips_APITests.cs | 39 + .../TableViewInteractionTestHelpers.cs | 19 + ...ableView_Accessibility_InteractionTests.cs | 35 + .../TableView_Keyboard_InteractionTests.cs | 7 +- .../TableView_Layout_InteractionTests.cs | 109 +- controls/dev/TableView/TableView.cpp | 994 ++++++---- controls/dev/TableView/TableView.h | 80 +- controls/dev/TableView/TableView.idl | 44 +- controls/dev/TableView/TableView.vcxitems | 3 +- controls/dev/TableView/TableView.xaml | 146 +- controls/dev/TableView/TableViewCell.h | 1 + .../TableView/TableViewCellAutomationPeer.cpp | 37 + .../TableView/TableViewCellAutomationPeer.h | 10 + controls/dev/TableView/TableViewColumn.cpp | 7 + .../dev/TableView/TableViewColumnHeader.cpp | 357 ++++ .../dev/TableView/TableViewColumnHeader.h | 78 + .../TableViewColumnHeaderAutomationPeer.cpp | 123 +- .../TableViewColumnHeaderAutomationPeer.h | 13 +- .../dev/TableView/TableViewGroupHeader.cpp | 55 + controls/dev/TableView/TableViewGroupHeader.h | 6 + controls/dev/TableView/TableViewHeaderCell.h | 210 --- controls/dev/TableView/TableViewRow.cpp | 212 ++- controls/dev/TableView/TableViewRow.h | 14 +- controls/dev/TableView/TableViewSource.h | 6 + controls/dev/TableView/TableView_Columns.cpp | 19 + controls/dev/TableView/TableView_Editing.cpp | 78 +- controls/dev/TableView/TableView_Layout.cpp | 88 +- controls/dev/TableView/TableView_Sort.cpp | 152 +- .../dev/TableView/TestUI/TableViewPage.xaml | 18 + .../TableView/TestUI/TableViewPage.xaml.cs | 83 +- .../TestUI/TableViewTestPageFacts.cs | 3 + .../XamlMetadataProviderGenerated.h | 37 + docs/api-specs/TableView/TableView-spec.md | 114 +- .../TabularControls/TableView-dev-spec.md | 74 +- .../TableView-functional-spec.md | 8 +- 57 files changed, 6072 insertions(+), 818 deletions(-) create mode 100644 controls/dev/TableView/TableViewColumnHeader.cpp create mode 100644 controls/dev/TableView/TableViewColumnHeader.h delete mode 100644 controls/dev/TableView/TableViewHeaderCell.h diff --git a/Samples/TableViewApp/TableViewAppCppPackaged/App.xaml.cpp b/Samples/TableViewApp/TableViewAppCppPackaged/App.xaml.cpp index 9c16ec52c..83c3826d2 100644 --- a/Samples/TableViewApp/TableViewAppCppPackaged/App.xaml.cpp +++ b/Samples/TableViewApp/TableViewAppCppPackaged/App.xaml.cpp @@ -9,8 +9,6 @@ namespace winrt::TableViewAppCppPackaged::implementation { App::App() { - HighContrastAdjustment(ApplicationHighContrastAdjustment::None); - #if defined _DEBUG && !defined DISABLE_XAML_GENERATED_BREAK_ON_UNHANDLED_EXCEPTION UnhandledException([](IInspectable const&, UnhandledExceptionEventArgs const& e) { diff --git a/Samples/TableViewApp/TableViewAppCppUnpackaged/App.xaml.cpp b/Samples/TableViewApp/TableViewAppCppUnpackaged/App.xaml.cpp index 82c1ab28a..c550fed56 100644 --- a/Samples/TableViewApp/TableViewAppCppUnpackaged/App.xaml.cpp +++ b/Samples/TableViewApp/TableViewAppCppUnpackaged/App.xaml.cpp @@ -9,8 +9,6 @@ namespace winrt::TableViewAppCppUnpackaged::implementation { App::App() { - HighContrastAdjustment(ApplicationHighContrastAdjustment::None); - #if defined _DEBUG && !defined DISABLE_XAML_GENERATED_BREAK_ON_UNHANDLED_EXCEPTION UnhandledException([](IInspectable const&, UnhandledExceptionEventArgs const& e) { diff --git a/Samples/TableViewApp/TableViewAppCsPackaged/App.xaml.cs b/Samples/TableViewApp/TableViewAppCsPackaged/App.xaml.cs index 74c3941b9..9df874b95 100644 --- a/Samples/TableViewApp/TableViewAppCsPackaged/App.xaml.cs +++ b/Samples/TableViewApp/TableViewAppCsPackaged/App.xaml.cs @@ -7,7 +7,6 @@ namespace TableViewAppCsPackaged public App() { this.InitializeComponent(); - HighContrastAdjustment = ApplicationHighContrastAdjustment.None; } protected override void OnLaunched(Microsoft.UI.Xaml.LaunchActivatedEventArgs args) diff --git a/Samples/TableViewApp/TableViewAppCsUnpackaged/App.xaml.cs b/Samples/TableViewApp/TableViewAppCsUnpackaged/App.xaml.cs index ac63ac38d..ae20e1ecf 100644 --- a/Samples/TableViewApp/TableViewAppCsUnpackaged/App.xaml.cs +++ b/Samples/TableViewApp/TableViewAppCsUnpackaged/App.xaml.cs @@ -7,7 +7,6 @@ namespace TableViewAppCsUnpackaged public App() { this.InitializeComponent(); - HighContrastAdjustment = ApplicationHighContrastAdjustment.None; } protected override void OnLaunched(Microsoft.UI.Xaml.LaunchActivatedEventArgs args) diff --git a/Samples/TableViewSampleApp/App.xaml.cs b/Samples/TableViewSampleApp/App.xaml.cs index 788d4c7ac..4158fe9c8 100644 --- a/Samples/TableViewSampleApp/App.xaml.cs +++ b/Samples/TableViewSampleApp/App.xaml.cs @@ -30,7 +30,6 @@ public partial class App : Application { _commandLineArguments = commandLineArguments ?? Array.Empty(); InitializeComponent(); - HighContrastAdjustment = ApplicationHighContrastAdjustment.None; } protected override void OnLaunched(LaunchActivatedEventArgs args) diff --git a/Samples/TableViewSampleApp/Controls/SamplePresenter.xaml b/Samples/TableViewSampleApp/Controls/SamplePresenter.xaml index ee6562c2f..a751f0f74 100644 --- a/Samples/TableViewSampleApp/Controls/SamplePresenter.xaml +++ b/Samples/TableViewSampleApp/Controls/SamplePresenter.xaml @@ -62,14 +62,14 @@ - + - + - 0.7 + 0.77 0 + @@ -119,12 +120,13 @@ - 0.7 + 0.7 0 + @@ -167,6 +169,7 @@ + diff --git a/controls/dev/Generated/TableView.properties.cpp b/controls/dev/Generated/TableView.properties.cpp index ef4bd9b53..de0913360 100644 --- a/controls/dev/Generated/TableView.properties.cpp +++ b/controls/dev/Generated/TableView.properties.cpp @@ -16,6 +16,7 @@ namespace winrt::Microsoft::UI::Xaml::Controls::Tabular GlobalDependencyProperty TableViewProperties::s_AlternatingRowBackgroundProperty{ nullptr }; GlobalDependencyProperty TableViewProperties::s_CanUserResizeColumnsProperty{ nullptr }; GlobalDependencyProperty TableViewProperties::s_CanUserSortColumnsProperty{ nullptr }; +GlobalDependencyProperty TableViewProperties::s_ColumnHeaderStyleProperty{ nullptr }; GlobalDependencyProperty TableViewProperties::s_ColumnsProperty{ nullptr }; GlobalDependencyProperty TableViewProperties::s_DensityProperty{ nullptr }; GlobalDependencyProperty TableViewProperties::s_EmptyTemplateProperty{ nullptr }; @@ -74,6 +75,17 @@ void TableViewProperties::EnsureProperties() ValueHelper::BoxValueIfNecessary(true), winrt::PropertyChangedCallback(&OnCanUserSortColumnsPropertyChanged)); } + if (!s_ColumnHeaderStyleProperty) + { + s_ColumnHeaderStyleProperty = + InitializeDependencyProperty( + L"ColumnHeaderStyle", + winrt::name_of(), + winrt::name_of(), + false /* isAttached */, + ValueHelper::BoxedDefaultValue(), + winrt::PropertyChangedCallback(&OnColumnHeaderStylePropertyChanged)); + } if (!s_ColumnsProperty) { s_ColumnsProperty = @@ -213,6 +225,7 @@ void TableViewProperties::ClearProperties() s_AlternatingRowBackgroundProperty = nullptr; s_CanUserResizeColumnsProperty = nullptr; s_CanUserSortColumnsProperty = nullptr; + s_ColumnHeaderStyleProperty = nullptr; s_ColumnsProperty = nullptr; s_DensityProperty = nullptr; s_EmptyTemplateProperty = nullptr; @@ -251,6 +264,14 @@ void TableViewProperties::OnCanUserSortColumnsPropertyChanged( winrt::get_self(owner)->OnCanUserSortColumnsPropertyChanged(args); } +void TableViewProperties::OnColumnHeaderStylePropertyChanged( + winrt::DependencyObject const& sender, + winrt::DependencyPropertyChangedEventArgs const& args) +{ + auto owner = sender.as(); + winrt::get_self(owner)->OnColumnHeaderStylePropertyChanged(args); +} + void TableViewProperties::OnColumnsPropertyChanged( winrt::DependencyObject const& sender, winrt::DependencyPropertyChangedEventArgs const& args) @@ -370,6 +391,19 @@ bool TableViewProperties::CanUserSortColumns() return ValueHelper::CastOrUnbox(static_cast(this)->GetValue(s_CanUserSortColumnsProperty)); } +void TableViewProperties::ColumnHeaderStyle(winrt::Style const& value) +{ + [[gsl::suppress(con)]] + { + static_cast(this)->SetValue(s_ColumnHeaderStyleProperty, ValueHelper::BoxValueIfNecessary(value)); + } +} + +winrt::Style TableViewProperties::ColumnHeaderStyle() +{ + return ValueHelper::CastOrUnbox(static_cast(this)->GetValue(s_ColumnHeaderStyleProperty)); +} + void TableViewProperties::Columns(winrt::IVector const& value) { [[gsl::suppress(con)]] diff --git a/controls/dev/Generated/TableView.properties.h b/controls/dev/Generated/TableView.properties.h index ef3e28a8d..df3bbf4c9 100644 --- a/controls/dev/Generated/TableView.properties.h +++ b/controls/dev/Generated/TableView.properties.h @@ -18,6 +18,9 @@ public: void CanUserSortColumns(bool value); bool CanUserSortColumns(); + void ColumnHeaderStyle(winrt::Style const& value); + winrt::Style ColumnHeaderStyle(); + void Columns(winrt::IVector const& value); winrt::IVector Columns(); @@ -57,6 +60,7 @@ public: static winrt::DependencyProperty AlternatingRowBackgroundProperty() { return s_AlternatingRowBackgroundProperty; } static winrt::DependencyProperty CanUserResizeColumnsProperty() { return s_CanUserResizeColumnsProperty; } static winrt::DependencyProperty CanUserSortColumnsProperty() { return s_CanUserSortColumnsProperty; } + static winrt::DependencyProperty ColumnHeaderStyleProperty() { return s_ColumnHeaderStyleProperty; } static winrt::DependencyProperty ColumnsProperty() { return s_ColumnsProperty; } static winrt::DependencyProperty DensityProperty() { return s_DensityProperty; } static winrt::DependencyProperty EmptyTemplateProperty() { return s_EmptyTemplateProperty; } @@ -73,6 +77,7 @@ public: static GlobalDependencyProperty s_AlternatingRowBackgroundProperty; static GlobalDependencyProperty s_CanUserResizeColumnsProperty; static GlobalDependencyProperty s_CanUserSortColumnsProperty; + static GlobalDependencyProperty s_ColumnHeaderStyleProperty; static GlobalDependencyProperty s_ColumnsProperty; static GlobalDependencyProperty s_DensityProperty; static GlobalDependencyProperty s_EmptyTemplateProperty; @@ -118,6 +123,10 @@ public: winrt::DependencyObject const& sender, winrt::DependencyPropertyChangedEventArgs const& args); + static void OnColumnHeaderStylePropertyChanged( + winrt::DependencyObject const& sender, + winrt::DependencyPropertyChangedEventArgs const& args); + static void OnColumnsPropertyChanged( winrt::DependencyObject const& sender, winrt::DependencyPropertyChangedEventArgs const& args); diff --git a/controls/dev/Generated/TableViewColumn.properties.cpp b/controls/dev/Generated/TableViewColumn.properties.cpp index b87f0ec81..98d49e174 100644 --- a/controls/dev/Generated/TableViewColumn.properties.cpp +++ b/controls/dev/Generated/TableViewColumn.properties.cpp @@ -21,6 +21,7 @@ GlobalDependencyProperty TableViewColumnProperties::s_CellEditingTemplatePropert GlobalDependencyProperty TableViewColumnProperties::s_FrozenEdgeProperty{ nullptr }; GlobalDependencyProperty TableViewColumnProperties::s_HeaderProperty{ nullptr }; GlobalDependencyProperty TableViewColumnProperties::s_HeaderContextFlyoutProperty{ nullptr }; +GlobalDependencyProperty TableViewColumnProperties::s_HeaderStyleProperty{ nullptr }; GlobalDependencyProperty TableViewColumnProperties::s_HeaderTemplateProperty{ nullptr }; GlobalDependencyProperty TableViewColumnProperties::s_HeaderTemplateSelectorProperty{ nullptr }; GlobalDependencyProperty TableViewColumnProperties::s_HeaderToolTipProperty{ nullptr }; @@ -128,6 +129,17 @@ void TableViewColumnProperties::EnsureProperties() ValueHelper::BoxedDefaultValue(), nullptr); } + if (!s_HeaderStyleProperty) + { + s_HeaderStyleProperty = + InitializeDependencyProperty( + L"HeaderStyle", + winrt::name_of(), + winrt::name_of(), + false /* isAttached */, + ValueHelper::BoxedDefaultValue(), + winrt::PropertyChangedCallback(&OnHeaderStylePropertyChanged)); + } if (!s_HeaderTemplateProperty) { s_HeaderTemplateProperty = @@ -261,6 +273,7 @@ void TableViewColumnProperties::ClearProperties() s_FrozenEdgeProperty = nullptr; s_HeaderProperty = nullptr; s_HeaderContextFlyoutProperty = nullptr; + s_HeaderStyleProperty = nullptr; s_HeaderTemplateProperty = nullptr; s_HeaderTemplateSelectorProperty = nullptr; s_HeaderToolTipProperty = nullptr; @@ -314,6 +327,14 @@ void TableViewColumnProperties::OnHeaderPropertyChanged( winrt::get_self(owner)->OnPropertyChanged(args); } +void TableViewColumnProperties::OnHeaderStylePropertyChanged( + winrt::DependencyObject const& sender, + winrt::DependencyPropertyChangedEventArgs const& args) +{ + auto owner = sender.as(); + winrt::get_self(owner)->OnPropertyChanged(args); +} + void TableViewColumnProperties::OnHeaderTemplatePropertyChanged( winrt::DependencyObject const& sender, winrt::DependencyPropertyChangedEventArgs const& args) @@ -482,6 +503,19 @@ winrt::FlyoutBase TableViewColumnProperties::HeaderContextFlyout() return ValueHelper::CastOrUnbox(static_cast(this)->GetValue(s_HeaderContextFlyoutProperty)); } +void TableViewColumnProperties::HeaderStyle(winrt::Style const& value) +{ + [[gsl::suppress(con)]] + { + static_cast(this)->SetValue(s_HeaderStyleProperty, ValueHelper::BoxValueIfNecessary(value)); + } +} + +winrt::Style TableViewColumnProperties::HeaderStyle() +{ + return ValueHelper::CastOrUnbox(static_cast(this)->GetValue(s_HeaderStyleProperty)); +} + void TableViewColumnProperties::HeaderTemplate(winrt::DataTemplate const& value) { [[gsl::suppress(con)]] diff --git a/controls/dev/Generated/TableViewColumn.properties.h b/controls/dev/Generated/TableViewColumn.properties.h index 9aaecccbb..5a8800b81 100644 --- a/controls/dev/Generated/TableViewColumn.properties.h +++ b/controls/dev/Generated/TableViewColumn.properties.h @@ -33,6 +33,9 @@ public: void HeaderContextFlyout(winrt::FlyoutBase const& value); winrt::FlyoutBase HeaderContextFlyout(); + void HeaderStyle(winrt::Style const& value); + winrt::Style HeaderStyle(); + void HeaderTemplate(winrt::DataTemplate const& value); winrt::DataTemplate HeaderTemplate(); @@ -74,6 +77,7 @@ public: static winrt::DependencyProperty FrozenEdgeProperty() { return s_FrozenEdgeProperty; } static winrt::DependencyProperty HeaderProperty() { return s_HeaderProperty; } static winrt::DependencyProperty HeaderContextFlyoutProperty() { return s_HeaderContextFlyoutProperty; } + static winrt::DependencyProperty HeaderStyleProperty() { return s_HeaderStyleProperty; } static winrt::DependencyProperty HeaderTemplateProperty() { return s_HeaderTemplateProperty; } static winrt::DependencyProperty HeaderTemplateSelectorProperty() { return s_HeaderTemplateSelectorProperty; } static winrt::DependencyProperty HeaderToolTipProperty() { return s_HeaderToolTipProperty; } @@ -94,6 +98,7 @@ public: static GlobalDependencyProperty s_FrozenEdgeProperty; static GlobalDependencyProperty s_HeaderProperty; static GlobalDependencyProperty s_HeaderContextFlyoutProperty; + static GlobalDependencyProperty s_HeaderStyleProperty; static GlobalDependencyProperty s_HeaderTemplateProperty; static GlobalDependencyProperty s_HeaderTemplateSelectorProperty; static GlobalDependencyProperty s_HeaderToolTipProperty; @@ -129,6 +134,10 @@ public: winrt::DependencyObject const& sender, winrt::DependencyPropertyChangedEventArgs const& args); + static void OnHeaderStylePropertyChanged( + winrt::DependencyObject const& sender, + winrt::DependencyPropertyChangedEventArgs const& args); + static void OnHeaderTemplatePropertyChanged( winrt::DependencyObject const& sender, winrt::DependencyPropertyChangedEventArgs const& args); diff --git a/controls/dev/TableView/APITests/TableViewSource_APITests.cs b/controls/dev/TableView/APITests/TableViewSource_APITests.cs index f7f6ba0a9..b31bc7bc3 100644 --- a/controls/dev/TableView/APITests/TableViewSource_APITests.cs +++ b/controls/dev/TableView/APITests/TableViewSource_APITests.cs @@ -782,6 +782,66 @@ namespace Microsoft.UI.Xaml.Tests.MUXControls.ApiTests }); } + [TestMethod] + [TestProperty("Description", "Verifies Sort(keySelector, None) removes the axis keyed by that delegate instance, a None for an unknown delegate leaves the sort unchanged, and ClearSort removes every key-selector axis.")] + public void VerifyKeySelectorSortNoneFollowsDelegateIdentity() + { + TableView tableView = null; + TableViewSource source = null; + List items = null; + TableViewKeySelector byLength = o => Person(o).Name.Length; + var byLengthAscending = new List { "Mei", "Asha", "Rafa", "Ines", "Owen", "Diego" }; + + RunOnUIThread.Execute(() => + { + items = MakeShapedPeople(); + source = TableViewSource.From(items).Sort(byLength, SortDirection.Ascending); + tableView = CreateShapedTable(source); + LoadContent(tableView); + }); + + SettleLayout(tableView); + + RunOnUIThread.Execute(() => + { + VerifyProjection(tableView, byLengthAscending, "baseline: sorted by name length"); + TableViewKeySelector sameKeyOtherInstance = o => Person(o).Name.Length; + source.Sort(sameKeyOtherInstance, SortDirection.None); + }); + + SettleLayout(tableView); + + RunOnUIThread.Execute(() => + { + VerifyProjection(tableView, byLengthAscending, "None with a delegate that keys no axis must leave the sort unchanged"); + source.Sort(byLength, SortDirection.None); + }); + + SettleLayout(tableView); + + RunOnUIThread.Execute(() => + { + VerifyProjection(tableView, items.Select(p => p.Name).ToList(), "None with the same delegate instance must remove its axis"); + source.Sort(byLength, SortDirection.Ascending).Sort(o => Person(o).Role, SortDirection.Descending); + }); + + SettleLayout(tableView); + + RunOnUIThread.Execute(() => + { + var labels = GetProjectedLabels(tableView, includeCount: true); + Verify.IsTrue(labels.First() == "Mei" && labels.Last() == "Diego", "Precondition: two key-selector axes, name length primary."); + source.ClearSort(); + }); + + SettleLayout(tableView); + + RunOnUIThread.Execute(() => + { + VerifyProjection(tableView, items.Select(p => p.Name).ToList(), "ClearSort must remove every key-selector axis"); + }); + } + [TestMethod] [TestProperty("Description", "Verifies SortDirection.None removes that axis rather than seeding a stage.")] public void VerifySortDirectionNoneRemovesThatAxis() diff --git a/controls/dev/TableView/APITests/TableView_AutomationPeer_APITests.cs b/controls/dev/TableView/APITests/TableView_AutomationPeer_APITests.cs index 665c896a7..27e4c2a84 100644 --- a/controls/dev/TableView/APITests/TableView_AutomationPeer_APITests.cs +++ b/controls/dev/TableView/APITests/TableView_AutomationPeer_APITests.cs @@ -10,6 +10,7 @@ using Microsoft.UI.Xaml.Controls; using Microsoft.UI.Xaml.Controls.Tabular; using Microsoft.UI.Xaml.Data; using Microsoft.UI.Xaml.Markup; +using Microsoft.UI.Xaml.Media; using MUXControlsTestApp.Utilities; using System; using System.Collections.Generic; @@ -632,6 +633,48 @@ namespace Microsoft.UI.Xaml.Tests.MUXControls.ApiTests }); } + [TestMethod] + [TestProperty("Description", "Verifies the public cell peer constructor rejects arguments that cannot describe a cell, and accepts ones that can.")] + public void VerifyCellPeerConstructorValidatesArguments() + { + TableView tableView = null; + TableView otherTable = null; + + RunOnUIThread.Execute(() => + { + tableView = CreateAutomationTable(); + otherTable = CreateAutomationTable(); + var host = new StackPanel(); + host.Children.Add(tableView); + host.Children.Add(otherTable); + LoadContent(host); + }); + + IdleSynchronizer.Wait(); + + RunOnUIThread.Execute(() => + { + var row = GetRealizedRows(tableView).First(); + var cell = GetRowCell(row, 0); + var column = tableView.Columns[0]; + int count = tableView.Columns.Count; + + Verify.Throws(() => new TableViewCellAutomationPeer(null, row, column, 0), "A null cell must be rejected."); + Verify.Throws(() => new TableViewCellAutomationPeer(cell, null, column, 0), "A null row must be rejected."); + Verify.Throws(() => new TableViewCellAutomationPeer(cell, row, null, 0), "A null column must be rejected."); + Verify.Throws(() => new TableViewCellAutomationPeer(cell, row, column, -1), "A negative column index must be rejected."); + Verify.Throws(() => new TableViewCellAutomationPeer(cell, row, column, count), "A column index past the table's columns must be rejected."); + Verify.Throws(() => new TableViewCellAutomationPeer(cell, row, otherTable.Columns[0], 0), "A column from another table must be rejected."); + + var peer = new TableViewCellAutomationPeer(cell, row, column, count - 1); + Verify.IsNotNull(peer, "In-range arguments must construct a peer."); + + // A row no TableView owns has no column set to check against, so only the index sign is enforced. + Verify.IsNotNull(new TableViewCellAutomationPeer(new Grid(), new TableViewRow(), new TableViewTextColumn(), 3), + "An unowned row must accept any non-negative index."); + }); + } + [TestMethod] [TestProperty("Description", "Verifies a cell peer whose row is gone throws ElementNotAvailable from GridItem.Row instead of reporting -1.")] public void VerifyStaleCellPeerGridItemRowThrowsElementNotAvailable() @@ -1627,6 +1670,411 @@ namespace Microsoft.UI.Xaml.Tests.MUXControls.ApiTests }); } + [TestMethod] + [TestProperty("Description", "Verifies a header disabled by its HeaderStyle reports IsEnabled=false on its realized and table-owned peers and refuses Invoke.")] + public void VerifyHeaderDisabledByStyleIsNotEnabledOrInvokable() + { + TableView tableView = null; + + RunOnUIThread.Execute(() => + { + tableView = CreateAutomationTable(); + tableView.CanUserSortColumns = true; + tableView.Columns[0].CanSort = true; + var disabledStyle = new Style(typeof(TableViewColumnHeader)); + disabledStyle.Setters.Add(new Setter(Control.IsEnabledProperty, false)); + tableView.Columns[0].HeaderStyle = disabledStyle; + LoadContent(tableView); + }); + + IdleSynchronizer.Wait(); + + RunOnUIThread.Execute(() => + { + var column = tableView.Columns[0]; + var header = GetHeaderCell(tableView, 0) as Control; + Verify.IsNotNull(header, "Precondition: the header cell is a Control."); + Verify.IsFalse(header.IsEnabled, "Precondition: the HeaderStyle disables the header."); + + var peers = new (AutomationPeer Peer, string Kind)[] + { + (FrameworkElementAutomationPeer.CreatePeerForElement(header), "realized"), + (new TableViewColumnHeaderAutomationPeer(tableView, column), "table-owned"), + }; + foreach (var (peer, kind) in peers) + { + Verify.IsFalse(peer.IsEnabled(), $"The {kind} peer of a disabled header must report IsEnabled=false."); + var invoke = peer.GetPattern(PatternInterface.Invoke) as IInvokeProvider; + Verify.IsNotNull(invoke, $"Precondition: the {kind} peer offers Invoke for a sortable column."); + VerifyFailsWithHResult(UIA_E_ELEMENTNOTENABLED, () => invoke.Invoke(), + $"Invoke on the {kind} peer of a disabled header must fail."); + } + + Verify.AreEqual(SortDirection.None, column.SortDirection, "A disabled header must not sort through UIA."); + }); + } + + private const int UIA_E_ELEMENTNOTENABLED = unchecked((int)0x80040200); + private const int UIA_E_INVALIDOPERATION = unchecked((int)0x80131509); + private const int E_INVALIDARG = unchecked((int)0x80070057); + + // By HRESULT rather than exception type: the projected type for a UIA_E_* code is not a contract. + private static void VerifyFailsWithHResult(int expectedHResult, Action action, string message) + { + try + { + action(); + } + catch (Exception e) + { + Verify.AreEqual(expectedHResult, e.HResult, $"{message} ({e.GetType().Name})"); + return; + } + + Verify.Fail($"{message} The call did not fail."); + } + + [TestMethod] + [TestProperty("Description", "Verifies the Transform pattern is offered only for columns the user may resize, and never moves or rotates.")] + public void VerifyTransformPatternTracksColumnResizability() + { + TableView tableView = null; + + RunOnUIThread.Execute(() => + { + tableView = TableViewSizingTestHelpers.CreateResizableTable( + ("First", new GridLength(150.0, GridUnitType.Pixel)), + ("Second", new GridLength(150.0, GridUnitType.Pixel))); + tableView.Columns[1].CanResize = false; + LoadContent(tableView); + }); + + IdleSynchronizer.Wait(); + + RunOnUIThread.Execute(() => + { + // UIA requires Transform on a HeaderItem the user can resize. + var transform = new TableViewColumnHeaderAutomationPeer(tableView, tableView.Columns[0]) + .GetPattern(PatternInterface.Transform) as ITransformProvider; + Verify.IsNotNull(transform, "A resizable column's header must offer the Transform pattern."); + Verify.IsTrue(transform.CanResize, "A resizable column must report CanResize."); + Verify.IsFalse(transform.CanMove, "A column header cannot be moved through Transform."); + Verify.IsFalse(transform.CanRotate, "A column header cannot be rotated."); + + Verify.IsNull(new TableViewColumnHeaderAutomationPeer(tableView, tableView.Columns[1]).GetPattern(PatternInterface.Transform), + "Transform must be withheld for a column whose CanResize is false."); + + tableView.CanUserResizeColumns = false; + Verify.IsNull(new TableViewColumnHeaderAutomationPeer(tableView, tableView.Columns[0]).GetPattern(PatternInterface.Transform), + "Transform must be withheld while CanUserResizeColumns is false."); + }); + } + + [TestMethod] + [TestProperty("Description", "Verifies a pinned column keeps the Transform pattern but reports CanResize=false and rejects Resize.")] + public void VerifyTransformCanResizeIsFalseForPinnedColumns() + { + TableView starTable = null; + TableView pixelTable = null; + + RunOnUIThread.Execute(() => + { + starTable = TableViewSizingTestHelpers.CreateResizableTable( + ("First", new GridLength(1.0, GridUnitType.Star)), + ("Second", new GridLength(1.0, GridUnitType.Star))); + LoadContent(starTable); + }); + + IdleSynchronizer.Wait(); + + RunOnUIThread.Execute(() => + { + // Same rule as gripper visibility: the last Star column has no later Star column to trade with. + var first = new TableViewColumnHeaderAutomationPeer(starTable, starTable.Columns[0]) + .GetPattern(PatternInterface.Transform) as ITransformProvider; + var last = new TableViewColumnHeaderAutomationPeer(starTable, starTable.Columns[1]) + .GetPattern(PatternInterface.Transform) as ITransformProvider; + Verify.IsNotNull(first, "The first Star column opts in to resizing, so Transform must be offered."); + Verify.IsNotNull(last, "The pinned Star column still opts in to resizing, so Transform must be offered."); + Verify.IsTrue(first.CanResize, "A Star column with a later Star donor can be resized."); + Verify.IsFalse(last.CanResize, "The last Star column is pinned, so CanResize must be false."); + + var widthBefore = starTable.Columns[1].Width; + VerifyFailsWithHResult(UIA_E_INVALIDOPERATION, () => last.Resize(80.0, 0.0), + "Resize on a pinned column must fail."); + Verify.AreEqual(widthBefore, starTable.Columns[1].Width, "A rejected Resize must not touch Width."); + + pixelTable = TableViewSizingTestHelpers.CreateResizableTable( + ("First", new GridLength(120.0, GridUnitType.Pixel))); + LoadContent(pixelTable); + }); + + IdleSynchronizer.Wait(); + + RunOnUIThread.Execute(() => + { + var fixedColumn = pixelTable.Columns[0]; + fixedColumn.MinWidth = 120.0; + fixedColumn.MaxWidth = 120.0; + var fixedTransform = new TableViewColumnHeaderAutomationPeer(pixelTable, fixedColumn) + .GetPattern(PatternInterface.Transform) as ITransformProvider; + Verify.IsNotNull(fixedTransform, "A fixed-width column that opts in to resizing still offers Transform."); + Verify.IsFalse(fixedTransform.CanResize, "MinWidth == MaxWidth pins the column, so CanResize must be false."); + + fixedColumn.MaxWidth = double.PositiveInfinity; + Verify.IsTrue(fixedTransform.CanResize, "Lifting MaxWidth must make the column resizable again."); + }); + } + + [TestMethod] + [TestProperty("Description", "Verifies Transform.Resize sets the column width through the resize path and clamps to MinWidth/MaxWidth.")] + public void VerifyTransformResizeSetsWidthAndClampsToBounds() + { + TableView tableView = null; + ITransformProvider transform = null; + + RunOnUIThread.Execute(() => + { + tableView = TableViewSizingTestHelpers.CreateResizableTable( + ("First", new GridLength(150.0, GridUnitType.Pixel)), + ("Second", new GridLength(150.0, GridUnitType.Pixel))); + tableView.Columns[0].MinWidth = 80.0; + tableView.Columns[0].MaxWidth = 260.0; + LoadContent(tableView); + }); + + IdleSynchronizer.Wait(); + + RunOnUIThread.Execute(() => + { + var column = tableView.Columns[0]; + transform = new TableViewColumnHeaderAutomationPeer(tableView, column) + .GetPattern(PatternInterface.Transform) as ITransformProvider; + Verify.IsNotNull(transform, "A resizable column's header must offer the Transform pattern."); + + // Height is ignored: a column only resizes along its width. UIA widths are physical pixels. + transform.Resize(200.0 * tableView.XamlRoot.RasterizationScale, 999.0); + Verify.AreEqual(GridUnitType.Pixel, column.Width.GridUnitType, "A resize writes a pixel width."); + Verify.AreEqual(200.0, column.Width.Value, "Resize must set the requested width."); + Content.UpdateLayout(); + }); + + IdleSynchronizer.Wait(); + + RunOnUIThread.Execute(() => + { + var column = tableView.Columns[0]; + Verify.AreEqual(200.0, column.ActualWidth, "ActualWidth must follow the width Resize wrote."); + Verify.AreEqual(150.0, tableView.Columns[1].Width.Value, "Resizing one column must not change another fixed column."); + + transform.Resize(1000.0 * tableView.XamlRoot.RasterizationScale, 0.0); + Verify.AreEqual(260.0, column.Width.Value, "Resize must clamp to MaxWidth."); + Content.UpdateLayout(); + + transform.Resize(10.0 * tableView.XamlRoot.RasterizationScale, 0.0); + Verify.AreEqual(80.0, column.Width.Value, "Resize must clamp to MinWidth."); + }); + } + + [TestMethod] + [TestProperty("Description", "Verifies Transform.Resize reads its width in physical pixels, the units UIA reports bounds in.")] + public void VerifyTransformResizeWidthIsPhysicalPixels() + { + TableView tableView = null; + + RunOnUIThread.Execute(() => + { + tableView = TableViewSizingTestHelpers.CreateResizableTable( + ("First", new GridLength(150.0, GridUnitType.Pixel)), + ("Second", new GridLength(150.0, GridUnitType.Pixel))); + LoadContent(tableView); + }); + + IdleSynchronizer.Wait(); + + RunOnUIThread.Execute(() => + { + var column = tableView.Columns[0]; + var transform = new TableViewColumnHeaderAutomationPeer(tableView, column) + .GetPattern(PatternInterface.Transform) as ITransformProvider; + Verify.IsNotNull(transform, "A resizable column's header must offer the Transform pattern."); + + var scale = tableView.XamlRoot.RasterizationScale; + Log.Comment($"RasterizationScale = {scale}"); + if (scale == 1.0) + { + // Not settable from a test, and at 1.0 pixels equal DIPs, so the conversion is unobservable. + Log.Warning("SKIPPED (reported as passed): the physical-pixel conversion cannot be observed at RasterizationScale 1.0; run on a scaled display to cover it."); + return; + } + + transform.Resize(180.0 * scale, 0.0); + Verify.IsTrue(Math.Abs(column.Width.Value - 180.0) < 0.01, + $"Resize must convert physical pixels to DIPs; expected 180, got {column.Width.Value}."); + }); + } + + [TestMethod] + [TestProperty("Description", "Verifies Transform.Resize converts physical pixels through a scale applied above the table, not only the display scale.")] + public void VerifyTransformResizeAccountsForAncestorScale() + { + TableView tableView = null; + + RunOnUIThread.Execute(() => + { + tableView = TableViewSizingTestHelpers.CreateResizableTable( + ("First", new GridLength(150.0, GridUnitType.Pixel)), + ("Second", new GridLength(150.0, GridUnitType.Pixel))); + var scaledHost = new Grid { RenderTransform = new ScaleTransform { ScaleX = 2.0, ScaleY = 2.0 } }; + scaledHost.Children.Add(tableView); + LoadContent(scaledHost); + }); + + IdleSynchronizer.Wait(); + + RunOnUIThread.Execute(() => + { + var column = tableView.Columns[0]; + var transform = new TableViewColumnHeaderAutomationPeer(tableView, column) + .GetPattern(PatternInterface.Transform) as ITransformProvider; + Verify.IsNotNull(transform, "A resizable column's header must offer the Transform pattern."); + + // On screen each DIP of the column spans this many physical pixels. + var pixelsPerDip = tableView.XamlRoot.RasterizationScale * 2.0; + transform.Resize(170.0 * pixelsPerDip, 0.0); + Verify.IsTrue(Math.Abs(column.Width.Value - 170.0) < 0.01, + $"Resize must convert physical pixels through the ancestor scale; expected 170, got {column.Width.Value}."); + }); + } + + [TestMethod] + [TestProperty("Description", "Verifies Transform.Resize applies a width change smaller than the pointer drag's jitter threshold.")] + public void VerifyTransformResizeAppliesSmallWidthChange() + { + TableView tableView = null; + + RunOnUIThread.Execute(() => + { + tableView = TableViewSizingTestHelpers.CreateResizableTable( + ("First", new GridLength(150.0, GridUnitType.Pixel)), + ("Second", new GridLength(150.0, GridUnitType.Pixel))); + LoadContent(tableView); + }); + + IdleSynchronizer.Wait(); + + RunOnUIThread.Execute(() => + { + var column = tableView.Columns[0]; + var transform = new TableViewColumnHeaderAutomationPeer(tableView, column) + .GetPattern(PatternInterface.Transform) as ITransformProvider; + Verify.IsNotNull(transform, "A resizable column's header must offer the Transform pattern."); + Verify.AreEqual(150.0, column.ActualWidth, "Precondition: the column starts at 150."); + + // One physical pixel at 300% scale; the pointer drag treats under half a DIP as jitter. + transform.Resize(150.3 * tableView.XamlRoot.RasterizationScale, 0.0); + Verify.IsTrue(Math.Abs(column.Width.Value - 150.3) < 0.01, + $"An absolute UIA width must be applied however small the change; expected 150.3, got {column.Width.Value}."); + }); + } + + [TestMethod] + [TestProperty("Description", "Verifies a Transform.Resize below the drag jitter threshold writes Width at most once, lands exactly next to a bound, and leaves an Auto column Auto when it asks for its current width.")] + [TestProperty("data:Case", "{NearMin, NearMax, AtMin, AutoNoop}")] + public void VerifyTransformResizeSmallWidthChangeWritesOnce() + { + var testCase = ((string)TestContext.DataRow["Case"]).Trim(); + TableView tableView = null; + + RunOnUIThread.Execute(() => + { + tableView = TableViewSizingTestHelpers.CreateResizableTable( + ("First", testCase == "AutoNoop" ? GridLength.Auto : new GridLength(150.0, GridUnitType.Pixel)), + ("Second", new GridLength(150.0, GridUnitType.Pixel))); + var first = tableView.Columns[0]; + switch (testCase) + { + case "NearMin": first.MinWidth = 100.0; first.Width = new GridLength(100.4, GridUnitType.Pixel); break; + case "NearMax": first.MaxWidth = 200.0; first.Width = new GridLength(199.6, GridUnitType.Pixel); break; + case "AtMin": first.MinWidth = 100.0; first.Width = new GridLength(100.0, GridUnitType.Pixel); break; + } + LoadContent(tableView); + }); + + IdleSynchronizer.Wait(); + + RunOnUIThread.Execute(() => + { + var column = tableView.Columns[0]; + var transform = new TableViewColumnHeaderAutomationPeer(tableView, column) + .GetPattern(PatternInterface.Transform) as ITransformProvider; + Verify.IsNotNull(transform, "A resizable column's header must offer the Transform pattern."); + + double target = column.ActualWidth + 0.0001; + double expected = column.ActualWidth; + switch (testCase) + { + case "NearMin": target = expected = 100.1; break; + case "NearMax": target = expected = 199.9; break; + case "AtMin": target = 99.7; expected = 100.0; break; + } + int expectedWrites = testCase == "NearMin" || testCase == "NearMax" ? 1 : 0; + + int widthWrites = 0; + var widths = new List(); + column.RegisterPropertyChangedCallback(TableViewColumn.WidthProperty, (s, dp) => + { + widthWrites++; + widths.Add(column.Width.ToString()); + }); + + transform.Resize(target * tableView.XamlRoot.RasterizationScale, 0.0); + Log.Comment($"{testCase}: Width writes [{string.Join(", ", widths)}], final {column.Width}."); + + Verify.AreEqual(expectedWrites, widthWrites, $"{testCase}: Width must be written {expectedWrites} time(s)."); + if (testCase == "AutoNoop") + { + Verify.AreEqual(GridUnitType.Auto, column.Width.GridUnitType, "A request for the current width must leave an Auto column Auto."); + } + else + { + Verify.IsTrue(Math.Abs(column.Width.Value - expected) < 0.01, $"{testCase}: expected {expected}, got {column.Width.Value}."); + } + }); + } + + [TestMethod] + [TestProperty("Description", "Verifies Transform.Move and Rotate fail, and Resize rejects a non-finite or negative width.")] + public void VerifyTransformMoveRotateAndInvalidResizeFail() + { + TableView tableView = null; + + RunOnUIThread.Execute(() => + { + tableView = TableViewSizingTestHelpers.CreateResizableTable( + ("First", new GridLength(150.0, GridUnitType.Pixel))); + LoadContent(tableView); + }); + + IdleSynchronizer.Wait(); + + RunOnUIThread.Execute(() => + { + var column = tableView.Columns[0]; + var transform = new TableViewColumnHeaderAutomationPeer(tableView, column) + .GetPattern(PatternInterface.Transform) as ITransformProvider; + Verify.IsNotNull(transform, "A resizable column's header must offer the Transform pattern."); + + VerifyFailsWithHResult(UIA_E_INVALIDOPERATION, () => transform.Move(10.0, 10.0), "Move must fail: CanMove is false."); + VerifyFailsWithHResult(UIA_E_INVALIDOPERATION, () => transform.Rotate(90.0), "Rotate must fail: CanRotate is false."); + VerifyFailsWithHResult(E_INVALIDARG, () => transform.Resize(double.NaN, 0.0), "Resize must reject NaN."); + VerifyFailsWithHResult(E_INVALIDARG, () => transform.Resize(double.PositiveInfinity, 0.0), "Resize must reject infinity."); + VerifyFailsWithHResult(E_INVALIDARG, () => transform.Resize(-1.0, 0.0), "Resize must reject a negative width."); + + Verify.AreEqual(150.0, column.Width.Value, "A failed Transform call must leave the width untouched."); + }); + } + [TestMethod] [TestProperty("Description", "Verifies a column header peer is a childless HeaderItem.")] public void VerifyColumnHeaderPeerIsALeafHeaderItem() diff --git a/controls/dev/TableView/APITests/TableView_Columns_APITests.cs b/controls/dev/TableView/APITests/TableView_Columns_APITests.cs index 0e6ea0fa8..c629657e5 100644 --- a/controls/dev/TableView/APITests/TableView_Columns_APITests.cs +++ b/controls/dev/TableView/APITests/TableView_Columns_APITests.cs @@ -25,8 +25,8 @@ namespace Microsoft.UI.Xaml.Tests.MUXControls.ApiTests // Category 3.1 of the TableView test plan: the Columns collection. // // These tests assert against two structures the control builds from Columns: - // - PART_HeaderHost, whose children are header cell Grids, each tagged with its column - // (TableView.cpp:1610, headerCell.Tag(column)). + // - PART_HeaderHost, whose children are TableViewColumnHeader controls, each tagged with its column + // (TableView::RebuildHeaders, header.Tag(column)). // - each realized TableViewRow's PART_CellsHost, whose children are Grid cell wrappers, // each tagged with its column (TableViewRow.cpp:762, cellWrapper.Tag(column)). // The Tag is the supported way to map a rendered element back to the column that produced it, @@ -416,6 +416,118 @@ namespace Microsoft.UI.Xaml.Tests.MUXControls.ApiTests }); } + [TestMethod] + [TestProperty("Description", "Verifies a header rebuild leaves focus where the app moved it while the focused header's Content was being cleared.")] + public void VerifyHeaderRebuildKeepsAppFocusMoveFromFocusedHeader() + { + TableView tableView = null; + TextBox external = null; + bool moved = false; + + RunOnUIThread.Execute(() => + { + tableView = CreateTableView(headers: new[] { "Name", "Role", "City" }); + external = new TextBox(); + var host = new StackPanel(); + host.Children.Add(external); + host.Children.Add(tableView); + LoadContent(host); + }); + + IdleSynchronizer.Wait(); + + RunOnUIThread.Execute(() => + { + var header = GetHeaderCell(tableView, 1) as ContentControl; + Verify.IsNotNull(header, "Precondition: the header cell is a ContentControl."); + Verify.IsTrue(header.Focus(FocusState.Keyboard), "Precondition: header 1 takes keyboard focus."); + header.RegisterPropertyChangedCallback(ContentControl.ContentProperty, (sender, property) => + { + if (!moved && ((ContentControl)sender).Content == null) + { + moved = true; + external.Focus(FocusState.Programmatic); + } + }); + tableView.CanUserSortColumns = false; + }); + + SettleLayout(tableView); + + RunOnUIThread.Execute(() => + { + Verify.IsTrue(moved, "Precondition: the rebuild cleared the focused header's Content."); + Verify.IsTrue(ReferenceEquals(external, FocusManager.GetFocusedElement(tableView.XamlRoot)), + "The rebuild must not pull focus back from where the app moved it."); + }); + } + + [TestMethod] + [TestProperty("Description", "Verifies a header rebuild leaves focus where the app moved it, out of the table, while the header's focused Button content is being released.")] + [TestProperty("data:Move", "{ContentCallback, LosingFocusRedirect}")] + public void VerifyHeaderRebuildKeepsAppFocusMoveFromFocusedHeaderContent() + { + var move = ((string)TestContext.DataRow["Move"]).Trim(); + TableView tableView = null; + TextBox external = null; + Button headerButton = null; + bool moved = false; + + RunOnUIThread.Execute(() => + { + tableView = CreateTableView(headers: new[] { "Name", "Role", "City" }); + headerButton = new Button { Content = "Role" }; + tableView.Columns[1].Header = headerButton; + external = new TextBox(); + var host = new StackPanel(); + host.Children.Add(external); + host.Children.Add(tableView); + LoadContent(host); + }); + + IdleSynchronizer.Wait(); + + RunOnUIThread.Execute(() => + { + var header = GetHeaderCell(tableView, 1) as ContentControl; + Verify.IsNotNull(header, "Precondition: the header cell is a ContentControl."); + Verify.IsTrue(ReferenceEquals(headerButton, header.Content), "Precondition: the header hosts the app's Button."); + Verify.IsTrue(headerButton.Focus(FocusState.Keyboard), "Precondition: the header's Button takes keyboard focus."); + if (move == "ContentCallback") + { + header.RegisterPropertyChangedCallback(ContentControl.ContentProperty, (sender, property) => + { + if (!moved && ((ContentControl)sender).Content == null) + { + moved = external.Focus(FocusState.Programmatic); + } + }); + } + else + { + // The app sends focus out of the table when its header content gives it up. + headerButton.LosingFocus += (sender, args) => + { + if (!moved) + { + moved = args.TrySetNewFocusedElement(external); + } + }; + } + tableView.CanUserSortColumns = false; + }); + + SettleLayout(tableView); + + RunOnUIThread.Execute(() => + { + Verify.IsTrue(moved, "Precondition: the app moved focus during the rebuild."); + var focused = FocusManager.GetFocusedElement(tableView.XamlRoot); + Log.Comment($"Focused after rebuild: {(focused as FrameworkElement)?.GetType().Name}"); + Verify.IsTrue(ReferenceEquals(external, focused), + "The rebuild must not pull focus back from where the app moved it."); + }); + } [TestMethod] [TestProperty("Description", "Verifies a focused header keeps focus across the header rebuild a theme change performs.")] public void VerifyHeaderFocusSurvivesThemeChange() diff --git a/controls/dev/TableView/APITests/TableView_Editing_APITests.cs b/controls/dev/TableView/APITests/TableView_Editing_APITests.cs index 3498d827b..d930b1a21 100644 --- a/controls/dev/TableView/APITests/TableView_Editing_APITests.cs +++ b/controls/dev/TableView/APITests/TableView_Editing_APITests.cs @@ -9,6 +9,7 @@ using Microsoft.UI.Xaml.Controls; using Microsoft.UI.Xaml.Controls.Primitives; using Microsoft.UI.Xaml.Controls.Tabular; using Microsoft.UI.Xaml.Data; +using Microsoft.UI.Xaml.Input; using Microsoft.UI.Xaml.Markup; using MUXControlsTestApp.Utilities; using System; @@ -548,6 +549,208 @@ namespace Microsoft.UI.Xaml.Tests.MUXControls.ApiTests Verify.AreEqual("Asha", items[0].Name, "The cancel must leave the pre-edit value in place."); }); } + + [TestMethod] + [TestProperty("Description", "Verifies a CanSort = false clear queued behind a sort on another column, both requested from CellEditEnding, does not clear that newer sort.")] + public void VerifyQueuedCanSortClearDoesNotClearNewerSortRequestedDuringEditEnding() + { + TableView tableView = null; + List items = null; + bool requested = false; + + RunOnUIThread.Execute(() => + { + items = MakeEditableItems(); + tableView = CreateEditableTable(items); + tableView.IsReadOnly = false; + LoadContent(tableView); + }); + IdleSynchronizer.Wait(); + + RunOnUIThread.Execute(() => + { + Verify.IsTrue(tableView.SortByColumn(tableView.Columns[0], SortDirection.Ascending), "Precondition: the Name column sorts."); + }); + IdleSynchronizer.Wait(); + + RunOnUIThread.Execute(() => + { + var name = tableView.Columns[0]; + var role = tableView.Columns[1]; + tableView.CellEditEnding += (s, e) => + { + if (requested) + { + return; + } + requested = true; + tableView.SortByColumn(role, SortDirection.Ascending); + name.CanSort = false; + }; + + var error = CaptureSetValue(GetValueProvider(tableView, items[0], columnIndex: 0), "Asha K"); + Verify.IsNull(error, "The commit must succeed."); + Verify.IsTrue(requested, "Precondition: CellEditEnding requested both changes."); + }); + IdleSynchronizer.Wait(); + + RunOnUIThread.Execute(() => + { + Verify.AreEqual(SortDirection.None, tableView.Columns[0].SortDirection, "The opted-out column must not stay sorted."); + Verify.AreEqual(SortDirection.Ascending, tableView.Columns[1].SortDirection, + "The stale opt-out clear must not remove the newer sort on another column."); + var roles = GetProjectedItems(tableView).Select(item => ((EditablePerson)item).Role).ToList(); + Verify.AreEqual("Architect,Designer,Engineer", string.Join(",", roles), "The rows must stay sorted by Role."); + }); + } + + [TestMethod] + [TestProperty("Description", "Verifies a sort opt-out made from a BeginningEdit handler, cancelled or not, still clears the opted-out sort.")] + [TestProperty("data:Change", "{CanSortCancel, CanSortContinue, CanUserSortColumnsCancel}")] + public void VerifySortOptOutDuringBeginningEditClearsSort() + { + var change = ((string)TestContext.DataRow["Change"]).Trim(); + TableView tableView = null; + List items = null; + bool requested = false; + int clearedCount = 0; + + RunOnUIThread.Execute(() => + { + items = MakeEditableItems(); + tableView = CreateEditableTable(items); + tableView.IsReadOnly = false; + LoadContent(tableView); + }); + IdleSynchronizer.Wait(); + + RunOnUIThread.Execute(() => + { + Verify.IsTrue(tableView.SortByColumn(tableView.Columns[0], SortDirection.Descending), "Precondition: the Name column sorts."); + }); + IdleSynchronizer.Wait(); + + RunOnUIThread.Execute(() => + { + var name = tableView.Columns[0]; + tableView.Sorted += (s, e) => + { + if (e.Direction == SortDirection.None) + { + clearedCount++; + } + }; + tableView.BeginningEdit += (s, e) => + { + if (requested) + { + return; + } + requested = true; + if (change == "CanUserSortColumnsCancel") + { + tableView.CanUserSortColumns = false; + } + else + { + name.CanSort = false; + } + e.Cancel = change != "CanSortContinue"; + }; + + var error = CaptureSetValue(GetValueProvider(tableView, items[0], columnIndex: 1), "Pilot"); + Log.Comment($"SetValue: {error?.Message ?? "succeeded"}"); + Verify.IsTrue(requested, "Precondition: BeginningEdit opted the sort out."); + if (change == "CanSortContinue") + { + Verify.IsNull(error, "An edit BeginningEdit let continue must accept the value."); + Verify.AreEqual("Pilot", items[0].Role, "The value must be committed."); + Verify.IsFalse(tableView.IsEditing, "The committed edit must be closed."); + } + }); + IdleSynchronizer.Wait(); + + RunOnUIThread.Execute(() => + { + Verify.AreEqual(SortDirection.None, tableView.Columns[0].SortDirection, $"{change}: the opted-out sort must be cleared."); + Verify.AreEqual(1, clearedCount, $"{change}: the clear must be reported once."); + var names = GetProjectedItems(tableView).Select(item => ((EditablePerson)item).Name).ToList(); + Verify.AreEqual("Asha,Diego,Mei", string.Join(",", names), $"{change}: the rows must return to source order."); + }); + } + + [TestMethod] + [TestProperty("Description", "Verifies a sort opt-out made from a BeginningEdit handler that lets the edit open keeps the edit open, and clears the sort when the edit closes.")] + public void VerifySortOptOutDuringBeginningEditKeepsTheEditOpen() + { + TableView tableView = null; + List items = null; + bool requested = false; + bool vetoClose = true; + int clearedCount = 0; + + RunOnUIThread.Execute(() => + { + items = MakeEditableItems(); + tableView = CreateEditableTable(items); + tableView.IsReadOnly = false; + LoadContent(tableView); + }); + IdleSynchronizer.Wait(); + + RunOnUIThread.Execute(() => + { + Verify.IsTrue(tableView.SortByColumn(tableView.Columns[0], SortDirection.Descending), "Precondition: the Name column sorts."); + }); + IdleSynchronizer.Wait(); + + RunOnUIThread.Execute(() => + { + var name = tableView.Columns[0]; + tableView.Sorted += (s, e) => + { + if (e.Direction == SortDirection.None) + { + clearedCount++; + } + }; + tableView.BeginningEdit += (s, e) => + { + if (!requested) + { + requested = true; + name.CanSort = false; + } + }; + // Holds the edit open after SetValue so its state can be observed. + tableView.CellEditEnding += (s, e) => e.Cancel = vetoClose; + + var error = CaptureSetValue(GetValueProvider(tableView, items[0], columnIndex: 1), "Pilot"); + Log.Comment($"SetValue: {error?.Message ?? "succeeded"}"); + Verify.IsTrue(requested, "Precondition: BeginningEdit opted the sort out."); + Verify.IsNotNull(error, "Precondition: the vetoed commit is reported."); + Verify.AreEqual(unchecked((int)0x80004005), error.HResult, "The edit must open and only the vetoed commit fail."); + Verify.IsTrue(tableView.IsEditing, "The opened edit must stay open."); + }); + IdleSynchronizer.Wait(); + + RunOnUIThread.Execute(() => + { + Verify.IsTrue(tableView.IsEditing, "The deferred sort clear must not close the open edit."); + Verify.AreEqual(0, clearedCount, "The sort clear waits for the edit to close."); + + vetoClose = false; + Verify.IsTrue(tableView.CommitEdit(), "The edit commits."); + Verify.AreEqual("Pilot", items[0].Role, "The value must be committed."); + }); + IdleSynchronizer.Wait(); + + RunOnUIThread.Execute(() => + { + Verify.AreEqual(SortDirection.None, tableView.Columns[0].SortDirection, "The opted-out sort must be cleared once the edit closes."); + Verify.AreEqual(1, clearedCount, "The clear must be reported once."); + }); + } } // Category 10.5 of the TableView test plan: validation, teardown and reentrancy. @@ -1632,6 +1835,95 @@ namespace Microsoft.UI.Xaml.Tests.MUXControls.ApiTests Verify.IsFalse(tableView.IsEditing, "And must close."); }); } + + [TestMethod] + [TestProperty("Description", "Verifies a BeginEdit whose editor creation throws rolls back: no edit is left open, the display cell and focus are restored, and a later edit works.")] + public void VerifyBeginEditThatThrowsRollsBackAndAllowsALaterEdit() + { + TableView tableView = null; + List items = null; + var converter = new ArmableThrowingConverter(); + + RunOnUIThread.Execute(() => + { + items = MakeEditableItems(); + tableView = CreateTableViewShell(items, 500, 300); + tableView.IsReadOnly = false; + + // An explicit Source makes the editor's binding evaluate inside the editor factory, so + // an armed converter throws out of BeginEdit itself rather than into a swallowed refresh. + tableView.Columns.Add(new TableViewTextColumn + { + Header = "Name", + Binding = new Binding { Path = new PropertyPath("Name"), Source = items[0], Converter = converter, Mode = BindingMode.OneWay }, + }); + tableView.Columns.Add(MakeTextColumn("Role")); + LoadContent(tableView); + }); + + IdleSynchronizer.Wait(); + + Grid cell = null; + UIElement displayContent = null; + RunOnUIThread.Execute(() => + { + cell = GetRowCell(GetRowForItem(tableView, items[0]), 0); + displayContent = GetCellContent(cell); + GetCellPeer(tableView, items[0], columnIndex: 0).SetFocus(); + }); + + IdleSynchronizer.Wait(); + + RunOnUIThread.Execute(() => + { + Verify.IsTrue(ReferenceEquals(cell, FocusManager.GetFocusedElement(tableView.XamlRoot)), + "Precondition: the cell has focus before the edit is attempted."); + + converter.Armed = true; + var error = CaptureSetValue(GetValueProvider(tableView, items[0], columnIndex: 0), "Asha Kapoor"); + converter.Armed = false; + + Verify.IsTrue(error != null, "The throwing editor factory must surface as a failed edit."); + Verify.IsFalse(tableView.IsEditing, "A BeginEdit that threw must not leave an edit open."); + Verify.IsTrue(ReferenceEquals(displayContent, GetCellContent(cell)), "The cell must show its display content again."); + }); + + IdleSynchronizer.Wait(); + + RunOnUIThread.Execute(() => + { + Verify.IsTrue(ReferenceEquals(cell, FocusManager.GetFocusedElement(tableView.XamlRoot)), + "Focus must be back on the cell after the rollback."); + + var error = CaptureSetValue(GetValueProvider(tableView, items[0], columnIndex: 0), "Asha Kapoor"); + Verify.IsTrue(error == null, $"A later edit on the same cell must start and commit. Instead: {error?.Message}"); + }); + + IdleSynchronizer.Wait(); + + RunOnUIThread.Execute(() => + { + Verify.AreEqual("Asha Kapoor", items[0].Name, "The later edit must commit."); + Verify.IsFalse(tableView.IsEditing, "And must close."); + }); + } + } + + // Throws from Convert only while armed, so the display cells build normally. + internal sealed class ArmableThrowingConverter : IValueConverter + { + public bool Armed { get; set; } + + public object Convert(object value, Type targetType, object parameter, string language) + { + if (Armed) + { + throw new InvalidOperationException("Deliberate editor-binding failure."); + } + return value; + } + + public object ConvertBack(object value, Type targetType, object parameter, string language) => value; } internal static class TableViewEditingTestHelpers diff --git a/controls/dev/TableView/APITests/TableView_Hierarchy_APITests.cs b/controls/dev/TableView/APITests/TableView_Hierarchy_APITests.cs index 9503162c9..89007d87d 100644 --- a/controls/dev/TableView/APITests/TableView_Hierarchy_APITests.cs +++ b/controls/dev/TableView/APITests/TableView_Hierarchy_APITests.cs @@ -12,7 +12,9 @@ using System.Collections.ObjectModel; using System.Collections.Specialized; using System.Linq; using System.Runtime.CompilerServices; +using Windows.Foundation; +using WEX.Logging.Interop; using WEX.TestExecution; using WEX.TestExecution.Markup; @@ -1294,6 +1296,32 @@ namespace Microsoft.UI.Xaml.Tests.MUXControls.ApiTests }); } + [TestMethod] + [TestProperty("Description", "Verifies a grouped root row is indented by the group header's expander gutter plus its spacing.")] + public void VerifyGroupedRootIndentMatchesGroupHeaderGutterAndSpacing() + { + TableViewSource source = null; + RunOnUIThread.Execute(() => source = Tree(MakeTree()).GroupBy(ByDept)); + var tableView = Place(source); + + RunOnUIThread.Execute(() => + { + var header = GetProjectedElements(tableView).OfType().First(); + var gutter = header.FindVisualChildByName("PART_ExpanderGutter") as FrameworkElement; + var gutterGrid = gutter == null ? null : Microsoft.UI.Xaml.Media.VisualTreeHelper.GetParent(gutter) as Grid; + Verify.IsNotNull(gutterGrid, "The group header should host PART_ExpanderGutter in a Grid."); + double expected = gutter.ActualWidth + gutterGrid.ColumnSpacing; + + var row = FindTreeRow(tableView, "Ada"); + double rowX = row.TransformToVisual(tableView).TransformPoint(new Point(0, 0)).X; + double chevronX = GetChevron(tableView, "Ada").TransformToVisual(tableView).TransformPoint(new Point(0, 0)).X; + Log.Comment($"Root indent={chevronX - rowX}, gutter + TableViewGroupExpanderSpacing={expected}, header Padding.Left={header.Padding.Left}"); + Verify.AreEqual(24.0 + 8.0, expected, "Default gutter (TableViewGroupExpanderSize) plus TableViewGroupExpanderSpacing."); + Verify.IsLessThan(Math.Abs(chevronX - rowX - expected), 0.5, + "A grouped root row's indent must equal the group header's gutter plus TableViewGroupExpanderSpacing."); + }); + } + #endregion #region Helpers diff --git a/controls/dev/TableView/APITests/TableView_NegativePath_APITests.cs b/controls/dev/TableView/APITests/TableView_NegativePath_APITests.cs index 6d11dc4fc..e8c6efbbe 100644 --- a/controls/dev/TableView/APITests/TableView_NegativePath_APITests.cs +++ b/controls/dev/TableView/APITests/TableView_NegativePath_APITests.cs @@ -63,6 +63,7 @@ namespace Microsoft.UI.Xaml.Tests.MUXControls.ApiTests resources["TabularSurfaceRowBackgroundBrush"] = new SolidColorBrush(Microsoft.UI.Colors.White); resources["TabularSurfaceRowForegroundBrush"] = new SolidColorBrush(Microsoft.UI.Colors.Black); resources["TabularSurfaceGridLineBrush"] = new SolidColorBrush(Microsoft.UI.Colors.Gray); + resources["TabularSurfaceOuterBorderBrush"] = new SolidColorBrush(Microsoft.UI.Colors.Gray); try { diff --git a/controls/dev/TableView/APITests/TableView_Recycling_APITests.cs b/controls/dev/TableView/APITests/TableView_Recycling_APITests.cs index aff8d3463..079c27a28 100644 --- a/controls/dev/TableView/APITests/TableView_Recycling_APITests.cs +++ b/controls/dev/TableView/APITests/TableView_Recycling_APITests.cs @@ -4,6 +4,7 @@ using Microsoft.UI.Xaml; using Microsoft.UI.Xaml.Controls; using Microsoft.UI.Xaml.Controls.Tabular; +using Microsoft.UI.Xaml.Media; using MUXControlsTestApp.Utilities; using System.Collections.Generic; using System.Linq; @@ -257,6 +258,220 @@ namespace Microsoft.UI.Xaml.Tests.MUXControls.ApiTests Verify.IsTrue(checkedAny, "Precondition: at least one realized row must be locatable in the projection."); }); } + + [TestMethod] + [TestProperty("Description", "Verifies a recycled row whose row peer exists but whose cells have no peers keeps its cell elements and shows the new item.")] + public void VerifyRecycledRowWithoutCellPeersReusesItsCells() + { + TableView tableView = null; + List items = null; + var cellsBefore = new Dictionary>(); + var contentBefore = new Dictionary>(); + + RunOnUIThread.Execute(() => + { + items = MakeManyItems(RecyclingSourceCount); + tableView = CreateScrollableTable(items); + LoadContent(tableView); + }); + + SettleLayout(tableView); + + RunOnUIThread.Execute(() => + { + foreach (var row in RealizedProjectedRows(tableView)) + { + // Cells are only enumerated, never asked for peers (FromElement would create one). + GetRowPeer(row); + cellsBefore[row] = GetCellsHost(row).Children.ToList(); + contentBefore[row] = cellsBefore[row].Select(cell => (object)GetCellContent((Grid)cell)).ToList(); + } + + Verify.IsGreaterThan(cellsBefore.Count, 0, "Precondition: rows must be realized before scrolling."); + }); + + ScrollToEnd(tableView); + + RunOnUIThread.Execute(() => + { + var recycledCount = 0; + + foreach (var row in RealizedProjectedRows(tableView)) + { + var item = row.DataContext as Person; + Verify.IsNotNull(item, "Every realized row must carry a data item."); + + var cells = GetCellsHost(row).Children.ToList(); + Verify.AreEqual(GetRowCellColumns(row).Count, cells.Count, "Every column must have a cell."); + Verify.AreEqual(item.Name, GetCellText((Grid)cells[0]), "A recycled row's first cell must show its current item."); + Verify.AreEqual(item.Role, GetCellText((Grid)cells[1]), "A recycled row's second cell must show its current item."); + + if (!cellsBefore.TryGetValue(row, out var previous)) + { + continue; + } + + recycledCount++; + for (var i = 0; i < cells.Count; i++) + { + Verify.IsTrue(ReferenceEquals(previous[i], cells[i]), + $"Recycling '{item.Name}' into a row with no cell peers must reuse cell {i}, not regenerate it."); + Verify.IsNotNull(contentBefore[row][i], $"Precondition: cell {i} had generated content before recycling."); + Verify.IsTrue(ReferenceEquals(contentBefore[row][i], GetCellContent((Grid)cells[i])), + $"Recycling '{item.Name}' must keep the generated content of cell {i}."); + } + } + + Log.Comment($"{recycledCount} recycled rows reused their cells."); + Verify.IsGreaterThan(recycledCount, 0, "Precondition: at least one container must have been recycled to a new item."); + }); + } + + [TestMethod] + [TestProperty("Description", "Verifies a recycled row whose cells already have automation peers replaces them, so a held cell provider never silently describes another item.")] + public void VerifyRecycledRowWithCellPeersReplacesItsCells() + { + TableView tableView = null; + List items = null; + var peersBefore = new Dictionary(); + var cellsBefore = new Dictionary(); + + RunOnUIThread.Execute(() => + { + items = MakeManyItems(RecyclingSourceCount); + tableView = CreateScrollableTable(items); + LoadContent(tableView); + }); + + SettleLayout(tableView); + + RunOnUIThread.Execute(() => + { + foreach (var row in RealizedProjectedRows(tableView)) + { + peersBefore[row] = GetCellPeer(row, 0); + cellsBefore[row] = GetRowCell(row, 0); + } + + Verify.IsGreaterThan(peersBefore.Count, 0, "Precondition: rows must be realized before scrolling."); + }); + + ScrollToEnd(tableView); + + RunOnUIThread.Execute(() => + { + var recycledCount = 0; + + foreach (var row in RealizedProjectedRows(tableView)) + { + var item = row.DataContext as Person; + Verify.IsNotNull(item, "Every realized row must carry a data item."); + + var cellPeer = GetCellPeer(row, 0); + var name = cellPeer.GetName(); + Verify.IsTrue(name != null && name.Contains(item.Name), + $"A recycled cell peer must report the current item's value; '{name}' does not mention '{item.Name}'."); + Verify.AreEqual(item.Name, GetCellText(GetRowCell(row, 0)), "A recycled row must show its current item."); + + if (!peersBefore.TryGetValue(row, out var previousPeer)) + { + continue; + } + + recycledCount++; + Verify.IsFalse(ReferenceEquals(previousPeer, cellPeer), + $"A row recycled to '{item.Name}' must not hand out the cell provider of its previous item."); + Verify.IsFalse(ReferenceEquals(cellsBefore[row], GetRowCell(row, 0)), + $"A row recycled to '{item.Name}' while its cells had peers must replace those cells."); + } + + Log.Comment($"{recycledCount} recycled rows replaced their cells."); + Verify.IsGreaterThan(recycledCount, 0, "Precondition: at least one container must have been recycled to a new item."); + }); + } + } + + // Recycled rows must repaint index- and table-dependent chrome for the slot they now occupy. + [TestClass] + public class TableViewRowChromeRecyclingTests : TableViewApiTestBase + { + [TestMethod] + [TestProperty("Description", "Verifies recycled rows carry the banding for their new index and draw grid lines, with and without a row peer.")] + public void VerifyRecycledRowsRepaintBandingAndGridLines() + { + TableView tableView = null; + List items = null; + Brush baseBrush = null; + Brush alternateBrush = null; + + RunOnUIThread.Execute(() => + { + items = MakeManyItems(RecyclingSourceCount); + baseBrush = new SolidColorBrush(Microsoft.UI.Colors.LightGray); + alternateBrush = new SolidColorBrush(Microsoft.UI.Colors.LightBlue); + tableView = CreateScrollableTable(items); + tableView.RowBackground = baseBrush; + tableView.AlternatingRowBackground = alternateBrush; + tableView.GridLinesVisibility = TableViewGridLinesVisibility.All; + LoadContent(tableView); + }); + + SettleLayout(tableView); + + RunOnUIThread.Execute(() => + { + // Every other row gets a peer so both recycle routes (with and without item tracking) are covered. + var rows = RealizedProjectedRows(tableView); + for (var i = 0; i < rows.Count; i += 2) + { + GetRowPeer(rows[i]); + } + + VerifyChrome(tableView, items, baseBrush, alternateBrush, "before scrolling"); + }); + + ScrollBodyTo(tableView, ScrollAxis.Vertical, 37.0 * 7); + RunOnUIThread.Execute(() => VerifyChrome(tableView, items, baseBrush, alternateBrush, "after a partial scroll")); + + ScrollToEnd(tableView); + RunOnUIThread.Execute(() => VerifyChrome(tableView, items, baseBrush, alternateBrush, "after scrolling to the end")); + + ScrollToStart(tableView); + RunOnUIThread.Execute(() => VerifyChrome(tableView, items, baseBrush, alternateBrush, "after scrolling back")); + } + + private static void VerifyChrome( + TableView tableView, + List items, + Brush baseBrush, + Brush alternateBrush, + string context) + { + var rows = RealizedProjectedRows(tableView); + Verify.IsGreaterThan(rows.Count, 0, $"Rows must be realized ({context})."); + + foreach (var row in rows) + { + var item = row.DataContext as Person; + var index = items.IndexOf(item); + Verify.IsGreaterThanOrEqual(index, 0, $"Every realized row must map to a source item ({context})."); + + Verify.AreEqual(item.Name, GetCellText(GetRowCell(row, 0)), $"Row {index} must show its own item ({context})."); + + var expected = (index % 2) == 0 ? baseBrush : alternateBrush; + Verify.AreEqual(expected, row.Background, $"Row {index} must carry the brush for its parity ({context})."); + + // The final row may suppress its bottom line where it meets the table border. + if (index != items.Count - 1) + { + Verify.IsGreaterThan(row.BorderThickness.Bottom, 0.0, $"Row {index} must draw its horizontal grid line ({context})."); + } + + var cell = GetRowCell(row, 0); + Verify.IsGreaterThan(cell.BorderThickness.Right, 0.0, $"Row {index} must draw its vertical grid line ({context})."); + Verify.IsNotNull(cell.BorderBrush, $"Row {index}'s vertical grid line must have a brush ({context})."); + } + } } // Grouping and sorting must not defeat virtualization. diff --git a/controls/dev/TableView/APITests/TableView_Sizing_APITests.cs b/controls/dev/TableView/APITests/TableView_Sizing_APITests.cs index c98627709..18f7e7e1d 100644 --- a/controls/dev/TableView/APITests/TableView_Sizing_APITests.cs +++ b/controls/dev/TableView/APITests/TableView_Sizing_APITests.cs @@ -1364,6 +1364,58 @@ namespace Microsoft.UI.Xaml.Tests.MUXControls.ApiTests } } + [TestMethod] + [TestProperty("Description", "Verifies re-parenting a gripper mid-drag keeps the drag: the stale Unloaded after the new Loaded must not cancel it.")] + public void VerifyReparentingGripperMidDragKeepsDrag() + { + ResizeGripper gripper = null; + StackPanel first = null; + StackPanel second = null; + var completions = new List(); + + RunOnUIThread.Execute(() => + { + gripper = new ResizeGripper { Width = 8, Height = 24 }; + gripper.DragCompleted += (s, e) => completions.Add(e.Canceled); + first = new StackPanel(); + second = new StackPanel(); + first.Children.Add(gripper); + var root = new StackPanel(); + root.Children.Add(first); + root.Children.Add(second); + LoadContent(root); + }); + + IdleSynchronizer.Wait(); + + RunOnUIThread.Execute(() => + { + gripper.BeginDrag(); + Verify.IsTrue(gripper.IsDragging, "Precondition: the drag starts."); + first.Children.Remove(gripper); + second.Children.Add(gripper); + }); + + IdleSynchronizer.Wait(); + + RunOnUIThread.Execute(() => + { + Verify.IsTrue(gripper.IsLoaded, "Precondition: the gripper is loaded in its new parent."); + Verify.IsTrue(gripper.IsDragging, "A re-parent must not end the drag."); + Verify.AreEqual(0, completions.Count, "A re-parent must not raise DragCompleted."); + + second.Children.Remove(gripper); + }); + + IdleSynchronizer.Wait(); + + RunOnUIThread.Execute(() => + { + Verify.IsFalse(gripper.IsDragging, "A real unload must end the drag."); + Verify.IsTrue(completions.Count == 1 && completions[0], "A real unload cancels the drag once."); + }); + } + [TestMethod] [TestProperty("Description", "Verifies a drag on a gripper that was never loaded completes without touching pointer capture.")] public void VerifyEndDragOnUnloadedGripperRaisesDragCompleted() @@ -2167,7 +2219,7 @@ namespace Microsoft.UI.Xaml.Tests.MUXControls.ApiTests var cellRight = GetVisualRight(headerCell, wrapper); Log.Comment($"Header cell spans {cellLeft}..{cellRight}."); - var gridLine = headerCell.FindVisualChildByName("TableViewHeaderGridLine"); + var gridLine = headerCell.FindVisualChildByName("PART_GridLine"); Verify.IsNotNull(gridLine, "The header cell must contain its vertical grid line."); Verify.AreEqual(Visibility.Visible, gridLine.Visibility, "Precondition: a non-terminal header grid line must be visible with GridLinesVisibility.All."); diff --git a/controls/dev/TableView/APITests/TableView_Sorting_APITests.cs b/controls/dev/TableView/APITests/TableView_Sorting_APITests.cs index fc174a8ea..7c4c3a80d 100644 --- a/controls/dev/TableView/APITests/TableView_Sorting_APITests.cs +++ b/controls/dev/TableView/APITests/TableView_Sorting_APITests.cs @@ -379,6 +379,186 @@ namespace Microsoft.UI.Xaml.Tests.MUXControls.ApiTests }); } + [TestMethod] + [TestProperty("Description", "Verifies SortByColumn refuses a column whose CanSort is false.")] + public void VerifySortByColumnRespectsCanSort() + { + TableView tableView = null; + var returned = true; + + RunOnUIThread.Execute(() => + { + tableView = CreateSortTable(); + tableView.Columns[0].CanSort = false; + LoadContent(tableView); + }); + + SettleLayout(tableView); + + RunOnUIThread.Execute(() => returned = tableView.SortByColumn(tableView.Columns[0], SortDirection.Ascending)); + + SettleLayout(tableView); + + RunOnUIThread.Execute(() => + { + Verify.IsFalse(returned, "SortByColumn must refuse a column with CanSort = false."); + Verify.AreEqual(SortDirection.None, tableView.Columns[0].SortDirection, + "A column with CanSort = false must not acquire a sort direction."); + + VerifySequence(SourceOrderNames, RowValues(tableView, person => person.Name), + "a column that opted out of sorting must leave the rows in source order"); + }); + } + + [TestMethod] + [TestProperty("Description", "Verifies setting CanSort = false on the sorted column clears its sort and raises Sorting/Sorted.")] + public void VerifyCanSortFalseClearsActiveSort() + { + TableView tableView = null; + var sortingArgs = new List(); + var sortedArgs = new List(); + + RunOnUIThread.Execute(() => + { + tableView = CreateSortTable(); + LoadContent(tableView); + }); + + SettleLayout(tableView); + + RunOnUIThread.Execute(() => + { + Verify.IsTrue(tableView.SortByColumn(tableView.Columns[0], SortDirection.Ascending), + "Baseline: the column must sort."); + tableView.Sorting += (sender, args) => sortingArgs.Add(args); + tableView.Sorted += (sender, args) => sortedArgs.Add(args); + }); + + SettleLayout(tableView); + + RunOnUIThread.Execute(() => + { + VerifySequence(NameAscendingOrder, RowValues(tableView, person => person.Name), + "baseline: the rows must be sorted"); + tableView.Columns[0].CanSort = false; + }); + + SettleLayout(tableView); + + RunOnUIThread.Execute(() => + { + var column = tableView.Columns[0]; + Verify.AreEqual(SortDirection.None, column.SortDirection, + "Opting a sorted column out of sorting must clear its sort direction."); + VerifySequence(SourceOrderNames, RowValues(tableView, person => person.Name), + "clearing the opted-out column's sort must restore source order"); + + var indicator = FindSortIndicator(tableView, 0); + Verify.IsTrue(indicator == null || indicator.Direction == SortIndicatorDirection.None, + "The header arrow must be cleared."); + + Verify.AreEqual(1, sortingArgs.Count, "The clear must raise Sorting once."); + Verify.AreEqual(column, sortingArgs[0].Column, "Sorting must name the opted-out column."); + Verify.AreEqual(SortDirection.None, sortingArgs[0].Direction, "Sorting must request None."); + Verify.AreEqual(1, sortedArgs.Count, "The clear must raise Sorted once."); + Verify.AreEqual(column, sortedArgs[0].Column, "Sorted must name the opted-out column."); + Verify.AreEqual(SortDirection.None, sortedArgs[0].Direction, "Sorted must report None."); + }); + } + + [TestMethod] + [TestProperty("Description", "Verifies a CanSort = false clear still removes the installed sort when the Sorting handler leaves the column without a sort path.")] + public void VerifyCanSortFalseClearRemovesSortWhenHandlerDropsSortPath() + { + TableView tableView = null; + var sortedArgs = new List(); + + RunOnUIThread.Execute(() => + { + tableView = CreateSortTable(); + LoadContent(tableView); + }); + + SettleLayout(tableView); + + RunOnUIThread.Execute(() => + { + Verify.IsTrue(tableView.SortByColumn(tableView.Columns[0], SortDirection.Ascending), + "Baseline: the column must sort."); + tableView.Sorting += (sender, args) => + { + if (args.Direction == SortDirection.None && args.Column is TableViewTextColumn column) + { + column.Binding = null; + } + }; + tableView.Sorted += (sender, args) => sortedArgs.Add(args); + }); + + SettleLayout(tableView); + + RunOnUIThread.Execute(() => + { + VerifySequence(NameAscendingOrder, RowValues(tableView, person => person.Name), + "baseline: the rows must be sorted"); + tableView.Columns[0].CanSort = false; + }); + + SettleLayout(tableView); + + RunOnUIThread.Execute(() => + { + Verify.AreEqual(SortDirection.None, tableView.Columns[0].SortDirection, "The opted-out column's sort must be cleared."); + VerifySequence(SourceOrderNames, RowValues(tableView, person => person.Name), + "the clear must remove the sort the column installed"); + Verify.AreEqual(1, sortedArgs.Count, "The clear must raise Sorted once."); + Verify.AreEqual(SortDirection.None, sortedArgs[0].Direction, "Sorted must report None."); + }); + } + + [TestMethod] + [TestProperty("Description", "Verifies a canceled CanSort = false clear keeps the sort, and SortByColumn(column, None) can still clear it.")] + public void VerifyCanceledCanSortClearCanBeClearedBySortByColumnNone() + { + TableView tableView = null; + var cancel = false; + + RunOnUIThread.Execute(() => + { + tableView = CreateSortTable(); + tableView.Sorting += (sender, args) => args.Cancel = cancel; + LoadContent(tableView); + }); + + SettleLayout(tableView); + + RunOnUIThread.Execute(() => + { + var column = tableView.Columns[0]; + Verify.IsTrue(tableView.SortByColumn(column, SortDirection.Ascending), "Baseline: the column must sort."); + + cancel = true; + column.CanSort = false; + cancel = false; + Verify.AreEqual(SortDirection.Ascending, column.SortDirection, + "A Sorting handler that cancels the opt-out clear keeps the sort."); + + Verify.IsFalse(tableView.SortByColumn(column, SortDirection.Descending), + "CanSort = false must still refuse a new direction."); + Verify.IsTrue(tableView.SortByColumn(column, SortDirection.None), + "SortByColumn(column, None) must clear a sort the opted-out column still holds."); + }); + + SettleLayout(tableView); + + RunOnUIThread.Execute(() => + { + Verify.AreEqual(SortDirection.None, tableView.Columns[0].SortDirection, "The sort must be cleared."); + VerifySequence(SourceOrderNames, RowValues(tableView, person => person.Name), + "clearing the sort must restore source order"); + }); + } + [TestMethod] [TestProperty("Description", "Verifies ClearSort resets every column and restores source order.")] public void VerifyClearSortClearsEveryColumnAndRestoresSourceOrder() @@ -1139,6 +1319,356 @@ namespace Microsoft.UI.Xaml.Tests.MUXControls.ApiTests }); } + [TestMethod] + [TestProperty("Description", "Verifies clearing a column lit by an app-declared TableViewSource sort removes that sort.")] + [TestProperty("data:Clear", "{SortByColumnNone, HeaderInvoke}")] + public void VerifyClearingColumnLitBySourceSortRemovesSourceSort() + { + var clear = ((string)TestContext.DataRow["Clear"]).Trim(); + TableView tableView = null; + TableViewSource source = null; + + RunOnUIThread.Execute(() => + { + source = TableViewSource.From(MakeShapedPeople()); + tableView = CreateSortTable(source); + tableView.Columns[0].SortCycle = TableViewSortCycle.AscendingDescendingNone; + LoadContent(tableView); + }); + + SettleLayout(tableView); + + RunOnUIThread.Execute(() => source.Sort("Name", SortDirection.Descending)); + + SettleLayout(tableView); + + RunOnUIThread.Execute(() => + { + Verify.AreEqual(SortDirection.Descending, tableView.Columns[0].SortDirection, + "Precondition: the source sort lights the Name column."); + VerifySequence(Enumerable.Reverse(NameAscendingOrder).ToList(), RowValues(tableView, person => person.Name), + "precondition: the source sort orders the rows"); + + switch (clear) + { + case "SortByColumnNone": + tableView.SortByColumn(tableView.Columns[0], SortDirection.None); + break; + default: + var provider = new TableViewColumnHeaderAutomationPeer(tableView, tableView.Columns[0]) + .GetPattern(PatternInterface.Invoke) as IInvokeProvider; + provider.Invoke(); + break; + } + }); + + SettleLayout(tableView); + + RunOnUIThread.Execute(() => + { + Verify.AreEqual(SortDirection.None, tableView.Columns[0].SortDirection, $"{clear}: the column must be cleared."); + VerifySequence(SourceOrderNames, RowValues(tableView, person => person.Name), + $"{clear}: the clear must remove the app-declared sort"); + }); + } + + [TestMethod] + [TestProperty("Description", "Verifies moving, replacing or ungating the columns keeps an app-declared TableViewSource sort and re-lights the matching column.")] + [TestProperty("data:Change", "{MoveColumn, ReplaceColumns, CanUserSortColumnsFalse}")] + public void VerifyImplicitClearKeepsSourceSort() + { + var change = ((string)TestContext.DataRow["Change"]).Trim(); + TableView tableView = null; + TableViewSource source = null; + var sortedArgs = new List(); + + RunOnUIThread.Execute(() => + { + source = TableViewSource.From(MakeShapedPeople()); + tableView = CreateSortTable(source); + LoadContent(tableView); + }); + + SettleLayout(tableView); + + RunOnUIThread.Execute(() => source.Sort("Name", SortDirection.Descending)); + + SettleLayout(tableView); + + RunOnUIThread.Execute(() => + { + Verify.AreEqual(SortDirection.Descending, tableView.Columns[0].SortDirection, + "Precondition: the source sort lights the Name column."); + tableView.Sorted += (s, e) => sortedArgs.Add(e); + + switch (change) + { + case "MoveColumn": + var column = tableView.Columns[0]; + tableView.Columns.RemoveAt(0); + tableView.Columns.Insert(1, column); + break; + case "ReplaceColumns": + tableView.Columns.Clear(); + tableView.Columns.Add(MakeTextColumn("Name")); + tableView.Columns.Add(MakeTextColumn("Role")); + break; + default: + tableView.CanUserSortColumns = false; + break; + } + }); + + SettleLayout(tableView); + + RunOnUIThread.Execute(() => + { + VerifySequence(Enumerable.Reverse(NameAscendingOrder).ToList(), RowValues(tableView, person => person.Name), + $"{change}: the app-declared sort must stay on the source"); + var nameColumn = tableView.Columns.First(c => c.Header as string == "Name"); + Verify.AreEqual(SortDirection.Descending, nameColumn.SortDirection, + $"{change}: the Name column must re-light from the source sort"); + Verify.IsFalse(sortedArgs.Any(e => e.Column == null && e.Direction == SortDirection.None), + $"{change}: a clear that left the source sort in place must not report that sorting was cleared"); + }); + } + + [TestMethod] + [TestProperty("Description", "Verifies turning CanUserSortColumns off right after the app sorts the source removes only the table's axis and reports no clear.")] + public void VerifyUngatingAfterAppSourceSortDoesNotReportClear() + { + TableView tableView = null; + TableViewSource source = null; + var sortedArgs = new List(); + + RunOnUIThread.Execute(() => + { + source = TableViewSource.From(MakeShapedPeople()); + tableView = CreateSortTable(source); + LoadContent(tableView); + }); + + SettleLayout(tableView); + + RunOnUIThread.Execute(() => + { + Verify.IsTrue(tableView.SortByColumn(tableView.Columns[0], SortDirection.Ascending), "Precondition: the Name column sorts."); + }); + + SettleLayout(tableView); + + RunOnUIThread.Execute(() => + { + tableView.Sorted += (s, e) => sortedArgs.Add(e); + // Same tick: the table's own axis is still installed when the gate closes. + source.Sort("Role", SortDirection.Descending); + tableView.CanUserSortColumns = false; + }); + + SettleLayout(tableView); + + RunOnUIThread.Execute(() => + { + Verify.IsFalse(sortedArgs.Any(e => e.Column == null && e.Direction == SortDirection.None), + "Removing the table's axis while the app's sort remains must not report that sorting was cleared."); + var roleColumn = tableView.Columns.First(c => c.Header as string == "Role"); + var nameColumn = tableView.Columns.First(c => c.Header as string == "Name"); + Verify.AreEqual(SortDirection.Descending, roleColumn.SortDirection, "The app's Role sort must light its column."); + Verify.AreEqual(SortDirection.None, nameColumn.SortDirection, "The table's own Name sort must be gone."); + }); + } + + [TestMethod] + [TestProperty("Description", "Verifies filter changes under an app sort no column describes raise no Sorted.")] + [TestProperty("data:Sort", "{KeySelector, UnmatchedPath}")] + public void VerifyFilterUnderUnmatchedAppSortRaisesNoSorted() + { + var sort = ((string)TestContext.DataRow["Sort"]).Trim(); + TableView tableView = null; + TableViewSource source = null; + int sortedCount = 0; + + RunOnUIThread.Execute(() => + { + source = TableViewSource.From(MakeShapedPeople()); + tableView = CreateSortTable(source); + LoadContent(tableView); + }); + + SettleLayout(tableView); + + RunOnUIThread.Execute(() => + { + if (sort == "KeySelector") + { + source.Sort(item => ((ShapedPerson)item).Name, SortDirection.Ascending); + } + else + { + source.Sort("DepartmentName", SortDirection.Ascending); + } + }); + + SettleLayout(tableView); + + for (int i = 0; i < 3; i++) + { + RunOnUIThread.Execute(() => + { + if (i == 0) + { + tableView.Sorted += (s, e) => sortedCount++; + } + source.Filter(_ => true); + }); + SettleLayout(tableView); + } + + RunOnUIThread.Execute(() => + { + Verify.AreEqual(0, sortedCount, $"{sort}: a filter change must not report a sort change."); + Verify.IsTrue(tableView.Columns.All(c => c.SortDirection == SortDirection.None), $"{sort}: no column describes the app's sort."); + }); + } + + [TestMethod] + [TestProperty("Description", "Verifies turning sorting off for the table or the column leaves a column the app's source sort lit untouched and raises no sort events.")] + [TestProperty("data:Change", "{CanUserSortColumnsFalse, CanSortFalse}")] + public void VerifyUngatingKeepsAppLitColumnWithoutEvents() + { + var change = ((string)TestContext.DataRow["Change"]).Trim(); + TableView tableView = null; + TableViewSource source = null; + int sortingCount = 0; + int sortedCount = 0; + int directionChanges = 0; + + RunOnUIThread.Execute(() => + { + source = TableViewSource.From(MakeShapedPeople()); + tableView = CreateSortTable(source); + LoadContent(tableView); + }); + + SettleLayout(tableView); + + RunOnUIThread.Execute(() => source.Sort("Name", SortDirection.Ascending)); + + SettleLayout(tableView); + + RunOnUIThread.Execute(() => + { + Verify.AreEqual(SortDirection.Ascending, tableView.Columns[0].SortDirection, "Precondition: the source sort lights the Name column."); + tableView.Sorting += (s, e) => sortingCount++; + tableView.Sorted += (s, e) => sortedCount++; + tableView.Columns[0].RegisterPropertyChangedCallback(TableViewColumn.SortDirectionProperty, (s, dp) => directionChanges++); + if (change == "CanSortFalse") + { + tableView.Columns[0].CanSort = false; + } + else + { + tableView.CanUserSortColumns = false; + } + }); + + SettleLayout(tableView); + + RunOnUIThread.Execute(() => + { + Verify.AreEqual(0, sortingCount, "The table installed no sort, so there is nothing to clear and no Sorting."); + Verify.AreEqual(0, sortedCount, "No Sorted may be raised."); + Verify.AreEqual(0, directionChanges, "The column's SortDirection must not flip."); + Verify.AreEqual(SortDirection.Ascending, tableView.Columns[0].SortDirection, "The column keeps describing the app's sort."); + VerifySequence(NameAscendingOrder, RowValues(tableView, person => person.Name), "the app's sort stays on the source"); + }); + } + + [TestMethod] + [TestProperty("Description", "Verifies a sort opt-out rebuilds the headers even when a Sorted handler throws.")] + [TestProperty("data:OptOut", "{CanSort, CanUserSortColumns}")] + public void VerifySortOptOutRebuildsHeadersWhenSortedHandlerThrows() + { + var optOut = ((string)TestContext.DataRow["OptOut"]).Trim(); + TableView tableView = null; + + RunOnUIThread.Execute(() => + { + tableView = CreateSortTable(); + LoadContent(tableView); + }); + + SettleLayout(tableView); + + RunOnUIThread.Execute(() => + { + Verify.IsTrue(tableView.SortByColumn(tableView.Columns[0], SortDirection.Ascending), "Precondition: the column sorts."); + tableView.Sorted += (s, e) => throw new InvalidOperationException("Sorted handler fault"); + try + { + if (optOut == "CanSort") + { + tableView.Columns[0].CanSort = false; + } + else + { + tableView.CanUserSortColumns = false; + } + } + catch (Exception e) + { + Log.Comment($"{optOut}: the handler fault surfaced: {e.Message}"); + } + }); + + SettleLayout(tableView); + + RunOnUIThread.Execute(() => + { + var indicator = FindSortIndicator(tableView, 0); + Verify.IsTrue(indicator == null || indicator.Visibility == Visibility.Collapsed, + $"{optOut}: the header must drop its sort affordance even though a Sorted handler threw."); + }); + } + + [TestMethod] + [TestProperty("Description", "Verifies a clear queued by removing the sorted column leaves the app-declared sort of the next ItemsSource alone.")] + public void VerifyColumnRemovalClearKeepsNewItemsSourceSort() + { + TableView tableView = null; + + RunOnUIThread.Execute(() => + { + tableView = CreateSortTable(TableViewSource.From(MakeShapedPeople())); + LoadContent(tableView); + }); + + SettleLayout(tableView); + + RunOnUIThread.Execute(() => Verify.IsTrue(tableView.SortByColumn(tableView.Columns[1], SortDirection.Ascending), + "Precondition: the Role column sorts.")); + + SettleLayout(tableView); + + RunOnUIThread.Execute(() => + { + tableView.Columns.RemoveAt(1); + var next = TableViewSource.From(MakeShapedPeople()); + next.Sort("Name", SortDirection.Descending); + tableView.ItemsSource = next; + }); + + SettleLayout(tableView); + + RunOnUIThread.Execute(() => + { + VerifySequence(Enumerable.Reverse(NameAscendingOrder).ToList(), RowValues(tableView, person => person.Name), + "the new ItemsSource must keep the sort the app declared on it"); + Verify.AreEqual(SortDirection.Descending, tableView.Columns[0].SortDirection, + "The Name column must reflect the new source's sort."); + }); + } + [TestMethod] [TestProperty("Description", "Verifies invoking a column header toggles the sort and updates the sort indicator.")] public void VerifyHeaderInvokeTogglesSortAndUpdatesIndicator() diff --git a/controls/dev/TableView/APITests/TableView_SourceOwnership_APITests.cs b/controls/dev/TableView/APITests/TableView_SourceOwnership_APITests.cs index ff1e47976..3bd5f8fb0 100644 --- a/controls/dev/TableView/APITests/TableView_SourceOwnership_APITests.cs +++ b/controls/dev/TableView/APITests/TableView_SourceOwnership_APITests.cs @@ -153,6 +153,136 @@ namespace Microsoft.UI.Xaml.Tests.MUXControls.ApiTests } }); } + + [TestMethod] + [TestProperty("Description", "Verifies clearing a column on an unloaded table leaves the sort of the shared TableViewSource its new owner shows.")] + public void VerifyUnloadedTableClearKeepsSharedSourceSort() + { + TableView first = null; + TableView second = null; + TableViewSource shared = null; + StackPanel host = null; + + RunOnUIThread.Execute(() => + { + shared = TableViewSource.From(MakeShapedPeople()); + first = CreateTableView(shared, headers: new[] { "Name", "Role" }, width: 500, height: 300); + host = new StackPanel(); + host.Children.Add(first); + LoadContent(host); + }); + + SettleLayout(first); + RunOnUIThread.Execute(() => shared.Sort("Name", SortDirection.Descending)); + SettleLayout(first); + + RunOnUIThread.Execute(() => + { + Verify.AreEqual(SortDirection.Descending, first.Columns[0].SortDirection, "Precondition: the source sort lights the first table."); + host.Children.Remove(first); + second = CreateTableView(shared, headers: new[] { "Name", "Role" }, width: 500, height: 300); + host.Children.Add(second); + }); + + SettleLayout(second); + + RunOnUIThread.Execute(() => + { + VerifySequence(Enumerable.Reverse(NameAscendingOrder).ToList(), RowValues(second, person => person.Name), + "precondition: the new owner shows the source sort"); + first.SortByColumn(first.Columns[0], SortDirection.None); + Verify.AreEqual(SortDirection.None, first.Columns[0].SortDirection, "The unloaded table clears its own column."); + }); + + SettleLayout(second); + + RunOnUIThread.Execute(() => + { + VerifySequence(Enumerable.Reverse(NameAscendingOrder).ToList(), RowValues(second, person => person.Name), + "a table that released the shared source must not clear the sort its new owner shows"); + Verify.AreEqual(SortDirection.Descending, second.Columns[0].SortDirection, "The new owner keeps its indicator."); + }); + } + + [TestMethod] + [TestProperty("Description", "Verifies a clear made before the table first loads still holds after it loads.")] + public void VerifyClearBeforeLoadedStaysCleared() + { + TableView tableView = null; + + RunOnUIThread.Execute(() => + { + tableView = CreateTableView(TableViewSource.From(MakeShapedPeople()), headers: new[] { "Name", "Role" }, width: 500, height: 300); + Verify.IsTrue(tableView.SortByColumn(tableView.Columns[0], SortDirection.Descending), "Precondition: the unloaded table sorts."); + Verify.IsTrue(tableView.ClearSort(), "Precondition: the unloaded table clears its sort."); + LoadContent(tableView); + }); + + SettleLayout(tableView); + + RunOnUIThread.Execute(() => + { + Verify.AreEqual(SortDirection.None, tableView.Columns[0].SortDirection, "The cleared column must stay cleared once the table loads."); + VerifySequence(SourceOrderNames, RowValues(tableView, person => person.Name), "the rows must stay in source order"); + }); + } + + [TestMethod] + [TestProperty("Description", "Verifies sorting an unloaded table leaves the shared TableViewSource its new owner shows unchanged.")] + public void VerifyUnloadedTableSortKeepsSharedSourceOrder() + { + TableView first = null; + TableView second = null; + TableViewSource shared = null; + StackPanel host = null; + int sortingCount = 0; + int sortedCount = 0; + + RunOnUIThread.Execute(() => + { + shared = TableViewSource.From(MakeShapedPeople()); + first = CreateTableView(shared, headers: new[] { "Name", "Role" }, width: 500, height: 300); + host = new StackPanel(); + host.Children.Add(first); + LoadContent(host); + }); + + SettleLayout(first); + RunOnUIThread.Execute(() => shared.Sort("Name", SortDirection.Descending)); + SettleLayout(first); + + RunOnUIThread.Execute(() => + { + Verify.AreEqual(SortDirection.Descending, first.Columns[0].SortDirection, "Precondition: the source sort lights the first table."); + host.Children.Remove(first); + second = CreateTableView(shared, headers: new[] { "Name", "Role" }, width: 500, height: 300); + host.Children.Add(second); + }); + + SettleLayout(second); + + RunOnUIThread.Execute(() => + { + VerifySequence(Enumerable.Reverse(NameAscendingOrder).ToList(), RowValues(second, person => person.Name), + "precondition: the new owner shows the source sort"); + first.Sorting += (s, e) => sortingCount++; + first.Sorted += (s, e) => sortedCount++; + Verify.IsFalse(first.SortByColumn(first.Columns[0], SortDirection.Ascending), + "A sort the shared source cannot take must report failure."); + }); + + SettleLayout(second); + + RunOnUIThread.Execute(() => + { + VerifySequence(Enumerable.Reverse(NameAscendingOrder).ToList(), RowValues(second, person => person.Name), + "a table that released the shared source must not re-sort the rows its new owner shows"); + Verify.AreEqual(SortDirection.Descending, second.Columns[0].SortDirection, "The new owner keeps its indicator."); + Verify.AreEqual(0, sortingCount, "A refused sort must not raise Sorting."); + Verify.AreEqual(0, sortedCount, "A refused sort must not raise Sorted."); + Verify.AreEqual(SortDirection.Descending, first.Columns[0].SortDirection, "A refused sort leaves the column state unchanged."); + }); + } } [TestClass] diff --git a/controls/dev/TableView/APITests/TableView_Theming_APITests.cs b/controls/dev/TableView/APITests/TableView_Theming_APITests.cs index 796960c17..ec183277f 100644 --- a/controls/dev/TableView/APITests/TableView_Theming_APITests.cs +++ b/controls/dev/TableView/APITests/TableView_Theming_APITests.cs @@ -91,7 +91,7 @@ namespace Microsoft.UI.Xaml.Tests.MUXControls.ApiTests // Hardcoded rather than looked up: TableViewGroupHeaderPadding is declared at the root // of TableView.xaml, the default-style page, which reaches a control through // DefaultStyleResourceUri and is never merged into Application.Current.Resources. - Verify.AreEqual(new Thickness(8, 6, 16, 6), groupHeader.Padding, + Verify.AreEqual(new Thickness(8, 8, 12, 8), groupHeader.Padding, "TableViewGroupHeader's default style must take Padding from TableViewGroupHeaderPadding."); }); } @@ -280,7 +280,7 @@ namespace Microsoft.UI.Xaml.Tests.MUXControls.ApiTests // Values mirror Common_themeresources_any.xaml. [TestMethod] - public void VerifyGridLineBrushesMatchFluentStrokeTokens() + public void VerifyGridLineAndOuterBorderBrushesMatchFluentStrokeTokens() { RunOnUIThread.Execute(() => { @@ -288,9 +288,11 @@ namespace Microsoft.UI.Xaml.Tests.MUXControls.ApiTests var dark = RequireBrush(resources, "Default", "TabularSurfaceGridLineBrush"); var light = RequireBrush(resources, "Light", "TabularSurfaceGridLineBrush"); + var darkBorder = RequireBrush(resources, "Default", "TabularSurfaceOuterBorderBrush"); + var lightBorder = RequireBrush(resources, "Light", "TabularSurfaceOuterBorderBrush"); var darkHeader = RequireBrush(resources, "Default", "TabularSurfaceHeaderSeparatorBrush"); var lightHeader = RequireBrush(resources, "Light", "TabularSurfaceHeaderSeparatorBrush"); - if (dark == null || light == null || darkHeader == null || lightHeader == null) + if (dark == null || light == null || darkBorder == null || lightBorder == null || darkHeader == null || lightHeader == null) { return; } @@ -299,11 +301,57 @@ namespace Microsoft.UI.Xaml.Tests.MUXControls.ApiTests "Dark TabularSurfaceGridLineBrush must be DividerStrokeColorDefault (#15FFFFFF)."); Verify.AreEqual(Color.FromArgb(0x0F, 0x00, 0x00, 0x00), light.Color, "Light TabularSurfaceGridLineBrush must be DividerStrokeColorDefault (#0F000000)."); + Verify.AreEqual(Color.FromArgb(0x19, 0x00, 0x00, 0x00), darkBorder.Color, + "Dark TabularSurfaceOuterBorderBrush must be CardStrokeColorDefault (#19000000)."); + Verify.AreEqual(Color.FromArgb(0x0F, 0x00, 0x00, 0x00), lightBorder.Color, + "Light TabularSurfaceOuterBorderBrush must be CardStrokeColorDefault (#0F000000)."); Verify.AreEqual(dark.Color, darkHeader.Color, "Dark header lines must match the body grid lines (DividerStrokeColorDefault)."); Verify.AreEqual(light.Color, lightHeader.Color, "Light header lines must match the body grid lines (DividerStrokeColorDefault)."); }); } + [TestMethod] + public void VerifyOuterBorderAndHeaderRuleUseTheirOwnBrushes() + { + var border = Color.FromArgb(0xFF, 0x20, 0x40, 0xC0); + var gridLine = Color.FromArgb(0xFF, 0xC0, 0x40, 0x20); + TableView tableView = null; + + RunOnUIThread.Execute(() => + { + EnsureTabularControlsResources(); + SetApplicationResource("TabularSurfaceOuterBorderBrush", new SolidColorBrush(border)); + SetApplicationResource("TabularSurfaceHeaderSeparatorBrush", new SolidColorBrush(gridLine)); + + tableView = CreateThemingTable(); + LoadContent(tableView); + }); + + IdleSynchronizer.Wait(); + + try + { + RunOnUIThread.Execute(() => + { + Verify.AreEqual(border, (tableView.BorderBrush as SolidColorBrush)?.Color ?? default, + "The outer border must come from TabularSurfaceOuterBorderBrush."); + + var headerRow = tableView.FindVisualChildByName("PART_HeaderRow") as Border; + Verify.IsNotNull(headerRow, "The template must contain PART_HeaderRow."); + Verify.AreEqual(gridLine, (headerRow?.BorderBrush as SolidColorBrush)?.Color ?? default, + "The header bottom rule must come from TabularSurfaceHeaderSeparatorBrush."); + }); + } + finally + { + RunOnUIThread.Execute(() => + { + ClearApplicationResource("TabularSurfaceOuterBorderBrush"); + ClearApplicationResource("TabularSurfaceHeaderSeparatorBrush"); + }); + } + } + // TableView-dev-spec.md:227-231 makes overriding a TabularSurface* key THE supported way to // restyle the table, in place of a monolithic style. If the control caches or hardcodes the // brush instead of resolving it, that story is fiction. @@ -361,6 +409,128 @@ namespace Microsoft.UI.Xaml.Tests.MUXControls.ApiTests } } + [TestMethod] + public void VerifyHeaderGridLinesShowWhateverTheBodyGridLines() + { + var separatorColor = Color.FromArgb(0xFF, 0x12, 0x9E, 0x5A); + var cases = new[] + { + TableViewGridLinesVisibility.None, + TableViewGridLinesVisibility.Horizontal, + TableViewGridLinesVisibility.Vertical, + TableViewGridLinesVisibility.All, + }; + + TableView tableView = null; + RunOnUIThread.Execute(() => + { + EnsureTabularControlsResources(); + SetApplicationResource("TabularSurfaceHeaderSeparatorBrush", new SolidColorBrush(separatorColor)); + + tableView = CreateThemingTable(); + LoadContent(tableView); + }); + + IdleSynchronizer.Wait(); + + try + { + foreach (var body in cases) + { + RunOnUIThread.Execute(() => + { + tableView.GridLinesVisibility = body; + Content.UpdateLayout(); + }); + + IdleSynchronizer.Wait(); + + RunOnUIThread.Execute(() => + { + var context = $"body {body}"; + bool bodyVertical = body == TableViewGridLinesVisibility.All || body == TableViewGridLinesVisibility.Vertical; + bool bodyHorizontal = body == TableViewGridLinesVisibility.All || body == TableViewGridLinesVisibility.Horizontal; + + var separator = GetHeaderCell(tableView, 0).FindVisualChildByName("PART_GridLine") as Border; + if (separator == null) + { + Verify.Fail("Every header cell must contain its separator."); + return; + } + + Verify.AreEqual(Visibility.Visible, separator.Visibility, + $"The non-terminal header separator is always shown ({context})."); + Verify.AreEqual(VerticalAlignment.Stretch, separator.VerticalAlignment, "Header separators are full height."); + Verify.AreEqual(separatorColor, (separator.Background as SolidColorBrush)?.Color ?? default, + "Header separators paint TabularSurfaceHeaderSeparatorBrush."); + + var headerRow = tableView.FindVisualChildByName("PART_HeaderRow") as Border; + Verify.IsNotNull(headerRow, "The template must contain PART_HeaderRow."); + if (headerRow != null) + { + Verify.IsTrue(headerRow.BorderThickness.Bottom > 0.0, $"The header bottom rule is always shown ({context})."); + Verify.AreEqual(separatorColor, (headerRow.BorderBrush as SolidColorBrush)?.Color ?? default, + "The header bottom rule paints TabularSurfaceHeaderSeparatorBrush."); + } + + var row = FindVisualChildrenByType(tableView).FirstOrDefault(); + if (row == null) + { + Verify.Fail("The TableView should realize at least one row."); + return; + } + + Verify.AreEqual(bodyHorizontal, row.BorderThickness.Bottom > 0.0, + $"Row dividers must follow GridLinesVisibility ({context})."); + Verify.AreEqual(bodyVertical, GetRowCell(row, 0).BorderThickness.Right > 0.0, + $"Body column separators must follow GridLinesVisibility ({context})."); + }); + } + } + finally + { + RunOnUIThread.Execute(() => ClearApplicationResource("TabularSurfaceHeaderSeparatorBrush")); + } + } + + [TestMethod] + public void VerifyTransparentHeaderSeparatorBrushHidesHeaderLines() + { + var transparent = Color.FromArgb(0x00, 0xFF, 0xFF, 0xFF); + TableView tableView = null; + + RunOnUIThread.Execute(() => + { + EnsureTabularControlsResources(); + SetApplicationResource("TabularSurfaceHeaderSeparatorBrush", new SolidColorBrush(transparent)); + + tableView = CreateThemingTable(); + LoadContent(tableView); + }); + + IdleSynchronizer.Wait(); + + try + { + RunOnUIThread.Execute(() => + { + var separator = GetHeaderCell(tableView, 0).FindVisualChildByName("PART_GridLine") as Border; + Verify.IsNotNull(separator, "Every header cell must contain its separator."); + Verify.AreEqual(transparent, (separator?.Background as SolidColorBrush)?.Color ?? default, + "Overriding TabularSurfaceHeaderSeparatorBrush with Transparent must hide the header separators."); + + var headerRow = tableView.FindVisualChildByName("PART_HeaderRow") as Border; + Verify.IsNotNull(headerRow, "The template must contain PART_HeaderRow."); + Verify.AreEqual(transparent, (headerRow?.BorderBrush as SolidColorBrush)?.Color ?? default, + "Overriding TabularSurfaceHeaderSeparatorBrush with Transparent must hide the header bottom rule."); + }); + } + finally + { + RunOnUIThread.Execute(() => ClearApplicationResource("TabularSurfaceHeaderSeparatorBrush")); + } + } + [TestMethod] public void VerifyGridLineBrushAppOverrideWinsInsideElementTheme() { @@ -1072,6 +1242,294 @@ namespace Microsoft.UI.Xaml.Tests.MUXControls.ApiTests } } + [TestMethod] + public void VerifyHighContrastHoveredAndPressedHeadersSuppressBackplate() + { + foreach (var state in new[] { "HighContrastPointerOver", "HighContrastPressed" }) + { + TableView tableView = null; + TableViewColumnHeader header = null; + + RunOnUIThread.Execute(() => + { + tableView = CreateThemingTable(); + LoadContent(tableView); + }); + + IdleSynchronizer.Wait(); + + RunOnUIThread.Execute(() => + { + header = RequireColumnHeader(tableView, 0); + Verify.IsTrue(VisualStateManager.GoToState(header, state, false), $"The header template must define the {state} state."); + }); + + IdleSynchronizer.Wait(); + + RunOnUIThread.Execute(() => + { + var presenter = header?.FindVisualChildByName("PART_ContentPresenter") as UIElement; + if (presenter == null) + { + Verify.Fail("The header template must contain PART_ContentPresenter."); + return; + } + + Verify.AreEqual(ElementHighContrastAdjustment.None, presenter.HighContrastAdjustment, + $"The {state} state must turn off the High Contrast text backplate on the header content."); + }); + } + } + + [TestMethod] + public void VerifyDefaultGroupHeaderCountInheritsForegroundThatFollowsStates() + { + TableView tableView = null; + TableViewGroupHeader groupHeader = null; + TextBlock count = null; + + RunOnUIThread.Execute(() => + { + EnsureTabularControlsResources(); + SetApplicationResource("TabularSurfaceGroupHeaderCountOpacity", 0.5); + + tableView = CreateGroupedThemingTable(); + LoadContent(tableView); + }); + + IdleSynchronizer.Wait(); + + Color PresenterColor() => + ((groupHeader.FindVisualChildByName("PART_ContentPresenter") as ContentPresenter)?.Foreground as SolidColorBrush)?.Color ?? default; + Color CountColor() => (count.Foreground as SolidColorBrush)?.Color ?? default; + + try + { + RunOnUIThread.Execute(() => + { + groupHeader = FindVisualChildrenByType(tableView).FirstOrDefault(); + count = groupHeader?.FindVisualChildByName("TableViewGroupHeaderItemCountText") as TextBlock; + if (count == null) + { + Verify.Fail("The default group header content must contain the item-count TextBlock."); + return; + } + + Verify.AreEqual(DependencyProperty.UnsetValue, count.ReadLocalValue(TextBlock.ForegroundProperty), + "The count must inherit Foreground rather than hold its own brush."); + Verify.AreEqual(0.5, count.Opacity, "The count must be dimmed with TabularSurfaceGroupHeaderCountOpacity."); + Verify.AreEqual(PresenterColor(), CountColor(), "At rest the count must use the header's Foreground."); + + var contentPresenter = groupHeader.FindVisualChildByName("PART_ContentPresenter") as ContentPresenter; + var key = contentPresenter == null ? null : + FindVisualChildrenByType(contentPresenter).FirstOrDefault(t => t != count); + Verify.IsNotNull(key, "The default group header content must contain the key TextBlock."); + Verify.AreEqual(DependencyProperty.UnsetValue, key?.ReadLocalValue(TextBlock.FontWeightProperty), + "Key text weight must come from a style, not a local value."); + Verify.AreEqual((ushort)600, key?.FontWeight.Weight ?? (ushort)0, + "Key text must be SemiBold (BodyStrongTextBlockStyle equivalent)."); + Verify.AreEqual(TextWrapping.NoWrap, key?.TextWrapping ?? TextWrapping.Wrap, "Key text stays on one line."); + + Verify.IsTrue(VisualStateManager.GoToState(groupHeader, "PointerOver", false), "PointerOver must exist."); + }); + + IdleSynchronizer.Wait(); + + RunOnUIThread.Execute(() => + { + Verify.AreEqual(PresenterColor(), CountColor(), "PointerOver must recolor the count with the key text."); + Verify.IsTrue(VisualStateManager.GoToState(groupHeader, "Disabled", false), "Disabled must exist."); + }); + + IdleSynchronizer.Wait(); + + RunOnUIThread.Execute(() => + { + Verify.AreEqual(PresenterColor(), CountColor(), "Disabled must give the count the same disabled brush as the key text."); + Verify.IsTrue(VisualStateManager.GoToState(groupHeader, "Normal", false), "Normal must exist."); + }); + + IdleSynchronizer.Wait(); + + RunOnUIThread.Execute(() => Verify.AreEqual(PresenterColor(), CountColor(), + "Returning to Normal must restore the rest brush.")); + } + finally + { + RunOnUIThread.Execute(() => ClearApplicationResource("TabularSurfaceGroupHeaderCountOpacity")); + } + } + + [TestMethod] + public void VerifyDisabledGroupHeaderCountIsNotDimmedTwice() + { + TableView tableView = null; + + RunOnUIThread.Execute(() => + { + EnsureTabularControlsResources(); + SetApplicationResource("TabularSurfaceGroupHeaderCountOpacity", 0.5); + + tableView = CreateGroupedThemingTable(); + tableView.IsEnabled = false; + LoadContent(tableView); + }); + + IdleSynchronizer.Wait(); + + TableViewGroupHeader header = null; + TextBlock Count() => header?.FindVisualChildByName("TableViewGroupHeaderItemCountText") as TextBlock; + + // IsEnabledChanged is raised asynchronously; the header's own handler was attached first. + using (var enabledChanged = new global::System.Threading.AutoResetEvent(false)) + { + try + { + RunOnUIThread.Execute(() => + { + header = FindVisualChildrenByType(tableView).FirstOrDefault(); + var count = Count(); + if (count == null) + { + Verify.Fail("The default group header content must contain the item-count TextBlock."); + return; + } + + Verify.AreEqual(1.0, count.Opacity, "A header loaded disabled must not dim its count on top of the disabled brush."); + header.IsEnabledChanged += (s, e) => + { + try { enabledChanged.Set(); } catch (ObjectDisposedException) { } + }; + tableView.IsEnabled = true; + }); + + Verify.IsTrue(enabledChanged.WaitOne(TimeSpan.FromSeconds(5)), "IsEnabledChanged must be raised."); + IdleSynchronizer.Wait(); + + RunOnUIThread.Execute(() => + { + Verify.AreEqual(0.5, Count().Opacity, "Enabling must restore TabularSurfaceGroupHeaderCountOpacity."); + tableView.IsEnabled = false; + }); + + Verify.IsTrue(enabledChanged.WaitOne(TimeSpan.FromSeconds(5)), "IsEnabledChanged must be raised."); + IdleSynchronizer.Wait(); + + RunOnUIThread.Execute(() => Verify.AreEqual(1.0, Count().Opacity, + "Disabling must not dim the count on top of the disabled brush.")); + } + finally + { + RunOnUIThread.Execute(() => ClearApplicationResource("TabularSurfaceGroupHeaderCountOpacity")); + } + } + } + + [TestMethod] + public void VerifyDisabledGroupHeaderCountFollowsContentTemplateChanges() + { + TableView tableView = null; + DataTemplate customTemplate = null; + const string CustomTemplateXaml = + @" + + + + + "; + + List Counts() => FindVisualChildrenByType(tableView) + .Select(header => header.FindVisualChildByName("TableViewGroupHeaderItemCountText") as TextBlock) + .ToList(); + + void VerifyCounts(double expected, string message) + { + var counts = Counts(); + Verify.IsGreaterThan(counts.Count, 0, "Precondition: group headers are realized."); + foreach (var count in counts) + { + if (count == null) + { + Verify.Fail("Every group header must contain the item-count TextBlock."); + return; + } + Verify.AreEqual(expected, count.Opacity, message); + } + } + + try + { + RunOnUIThread.Execute(() => + { + EnsureTabularControlsResources(); + SetApplicationResource("TabularSurfaceGroupHeaderCountOpacity", 0.5); + customTemplate = (DataTemplate)Microsoft.UI.Xaml.Markup.XamlReader.Load(CustomTemplateXaml); + + tableView = CreateGroupedThemingTable(); + tableView.IsEnabled = false; + LoadContent(tableView); + }); + IdleSynchronizer.Wait(); + + RunOnUIThread.Execute(() => + { + VerifyCounts(1.0, "Precondition: a disabled default count is not dimmed twice."); + tableView.GroupHeaderTemplate = customTemplate; + }); + IdleSynchronizer.Wait(); + + RunOnUIThread.Execute(() => + { + VerifyCounts(0.25, "An app template's same-named TextBlock must keep its own Opacity."); + tableView.GroupHeaderTemplate = null; + }); + IdleSynchronizer.Wait(); + + RunOnUIThread.Execute(() => VerifyCounts(1.0, + "A default count realized again while disabled must not be dimmed twice.")); + } + finally + { + RunOnUIThread.Execute(() => ClearApplicationResource("TabularSurfaceGroupHeaderCountOpacity")); + } + } + + [TestMethod] + public void VerifyGroupHeaderCountFollowsAppForegroundStyle() + { + var white = Color.FromArgb(0xFF, 0xFF, 0xFF, 0xFF); + TableView tableView = null; + + RunOnUIThread.Execute(() => + { + EnsureTabularControlsResources(); + tableView = CreateGroupedThemingTable(); + tableView.RequestedTheme = ElementTheme.Light; + var style = new Style(typeof(TableViewGroupHeader)); + style.Setters.Add(new Setter(Control.BackgroundProperty, new SolidColorBrush(Color.FromArgb(0xFF, 0x00, 0x5F, 0xB8)))); + style.Setters.Add(new Setter(Control.ForegroundProperty, new SolidColorBrush(white))); + tableView.Resources[typeof(TableViewGroupHeader)] = style; + LoadContent(tableView); + }); + + IdleSynchronizer.Wait(); + + RunOnUIThread.Execute(() => + { + var groupHeader = FindVisualChildrenByType(tableView).FirstOrDefault(); + var count = groupHeader?.FindVisualChildByName("TableViewGroupHeaderItemCountText") as TextBlock; + if (count == null) + { + Verify.Fail("The default group header content must contain the item-count TextBlock."); + return; + } + + Verify.AreEqual(white, (count.Foreground as SolidColorBrush)?.Color ?? default, + "An app's header Foreground must reach the count so it stays readable on the app's Background."); + }); + } + #endregion #region 13.3 Density @@ -1211,7 +1669,7 @@ namespace Microsoft.UI.Xaml.Tests.MUXControls.ApiTests IdleSynchronizer.Wait(); VerifyRoundedOnlyWhileKeyboardFocused(tableView, - () => GetHeaderCell(tableView, 0) as Grid, () => GetHeaderCell(tableView, 1), "header cell"); + () => GetHeaderCell(tableView, 0) as TableViewColumnHeader, () => GetHeaderCell(tableView, 1), "header cell"); } [TestMethod] @@ -1234,8 +1692,11 @@ namespace Microsoft.UI.Xaml.Tests.MUXControls.ApiTests } // A rounded Grid clips its children and hit-testing, so it may round only while keyboard-focused. + private static CornerRadius RadiusOf(FrameworkElement element) + => element is Control control ? control.CornerRadius : ((Grid)element).CornerRadius; + private static void VerifyRoundedOnlyWhileKeyboardFocused( - TableView tableView, Func getTarget, Func getOther, string what) + TableView tableView, Func getTarget, Func getOther, string what) { var expected = new CornerRadius(0); @@ -1247,10 +1708,10 @@ namespace Microsoft.UI.Xaml.Tests.MUXControls.ApiTests Verify.AreNotEqual(new CornerRadius(0), expected, "The row style should round row focus."); var target = getTarget(); - Verify.IsNotNull(target, $"The {what} should be a Grid."); + Verify.IsNotNull(target, $"The {what} should exist."); Verify.IsTrue(target.UseSystemFocusVisuals, $"The {what} should use the system focus visual."); - Verify.AreEqual(new CornerRadius(0), target.CornerRadius, - $"An unfocused {what} must stay square: a rounded Grid clips its children."); + Verify.AreEqual(new CornerRadius(0), RadiusOf(target), + $"An unfocused {what} must stay square: a rounded root clips its children."); target.IsTabStop = true; Verify.IsTrue(target.Focus(FocusState.Keyboard), $"Precondition: the {what} takes keyboard focus."); }); @@ -1263,7 +1724,7 @@ namespace Microsoft.UI.Xaml.Tests.MUXControls.ApiTests Verify.IsTrue(ReferenceEquals(target, FocusManager.GetFocusedElement(target.XamlRoot)), $"Precondition: keyboard focus stays on the {what}."); Verify.AreEqual(FocusState.Keyboard, target.FocusState, $"Precondition: the {what} is keyboard-focused."); - Verify.AreEqual(expected, target.CornerRadius, + Verify.AreEqual(expected, RadiusOf(target), $"A keyboard-focused {what} must be rounded like the rows' focus rect."); Verify.IsTrue(getOther().Focus(FocusState.Programmatic), "Precondition: focus moves elsewhere."); }); @@ -1273,7 +1734,7 @@ namespace Microsoft.UI.Xaml.Tests.MUXControls.ApiTests RunOnUIThread.Execute(() => { var target = getTarget(); - Verify.AreEqual(new CornerRadius(0), target.CornerRadius, $"The {what} must drop its radius when it loses focus."); + Verify.AreEqual(new CornerRadius(0), RadiusOf(target), $"The {what} must drop its radius when it loses focus."); target.IsTabStop = true; Verify.IsTrue(target.Focus(FocusState.Pointer), $"Precondition: the {what} takes pointer focus."); }); @@ -1282,7 +1743,7 @@ namespace Microsoft.UI.Xaml.Tests.MUXControls.ApiTests RunOnUIThread.Execute(() => { - Verify.AreEqual(new CornerRadius(0), getTarget().CornerRadius, + Verify.AreEqual(new CornerRadius(0), RadiusOf(getTarget()), $"Pointer focus shows no focus rect, so the {what} must stay square."); Verify.IsTrue(getOther().Focus(FocusState.Programmatic), "Precondition: focus moves elsewhere."); }); @@ -1308,7 +1769,7 @@ namespace Microsoft.UI.Xaml.Tests.MUXControls.ApiTests Log.Comment($"Programmatic focus on the {what} was coerced to {state}."); Verify.IsTrue(state == FocusState.Keyboard || state == FocusState.Pointer, $"Precondition: programmatic focus on the {what} is coerced to Keyboard or Pointer."); - Verify.AreEqual(state == FocusState.Keyboard ? expected : new CornerRadius(0), target.CornerRadius, + Verify.AreEqual(state == FocusState.Keyboard ? expected : new CornerRadius(0), RadiusOf(target), $"Programmatic focus shows the focus rect only after keyboard input, so the {what} is rounded only then."); }); } @@ -1614,6 +2075,1154 @@ namespace Microsoft.UI.Xaml.Tests.MUXControls.ApiTests #endregion + #region 13.4 Column header style + + private static TableViewColumnHeader RequireColumnHeader(TableView tableView, int index) + { + var header = GetHeaderCell(tableView, index) as TableViewColumnHeader; + Verify.IsNotNull(header, $"Header {index} should be a TableViewColumnHeader."); + return header; + } + + private static string CurrentState(TableViewColumnHeader header, string groupName) + { + var root = VisualTreeHelper.GetChild(header, 0) as FrameworkElement; + Verify.IsNotNull(root, "The header template should have applied."); + var group = VisualStateManager.GetVisualStateGroups(root).FirstOrDefault(g => g.Name == groupName); + Verify.IsNotNull(group, $"The header template should declare {groupName}."); + return group.CurrentState?.Name; + } + + [TestMethod] + [TestProperty("Description", "Verifies TableView.ColumnHeaderStyle reaches every header, TableViewColumn.HeaderStyle wins for its column, and a style Padding/MinHeight beats the density values.")] + public void VerifyColumnHeaderStyleApplies() + { + TableView tableView = null; + SolidColorBrush tableBrush = null; + SolidColorBrush columnBrush = null; + var stylePadding = new Thickness(20, 1, 20, 1); + + RunOnUIThread.Execute(() => + { + tableBrush = new SolidColorBrush(Microsoft.UI.Colors.Red); + columnBrush = new SolidColorBrush(Microsoft.UI.Colors.Green); + tableView = CreateThemingTable(); + var tableStyle = new Style(typeof(TableViewColumnHeader)); + tableStyle.Setters.Add(new Setter(Control.BackgroundProperty, tableBrush)); + tableStyle.Setters.Add(new Setter(Control.FontWeightProperty, Microsoft.UI.Text.FontWeights.Bold)); + tableStyle.Setters.Add(new Setter(Control.PaddingProperty, stylePadding)); + tableStyle.Setters.Add(new Setter(FrameworkElement.MinHeightProperty, 60.0)); + tableView.ColumnHeaderStyle = tableStyle; + + var columnStyle = new Style(typeof(TableViewColumnHeader)); + columnStyle.Setters.Add(new Setter(Control.BackgroundProperty, columnBrush)); + tableView.Columns[1].HeaderStyle = columnStyle; + LoadContent(tableView); + }); + + IdleSynchronizer.Wait(); + + RunOnUIThread.Execute(() => + { + var first = RequireColumnHeader(tableView, 0); + Verify.IsTrue(ReferenceEquals(tableBrush, first.Background), "ColumnHeaderStyle's Background setter must reach the header."); + Verify.AreEqual((ushort)700, GetHeaderPresenter(tableView, 0).FontWeight.Weight, + "ColumnHeaderStyle's FontWeight setter must reach the header text."); + Verify.AreEqual(stylePadding, first.Padding, "A style Padding must win over the density padding."); + Verify.AreEqual(60.0, first.MinHeight, "A style MinHeight must win over the density row height."); + + var second = RequireColumnHeader(tableView, 1); + Verify.IsTrue(ReferenceEquals(tableView.Columns[1], second.Column), "A header's Column must be the column it represents."); + Verify.IsTrue(ReferenceEquals(columnBrush, second.Background), "TableViewColumn.HeaderStyle must win over ColumnHeaderStyle."); + Verify.AreEqual((ushort)600, GetHeaderPresenter(tableView, 1).FontWeight.Weight, + "A column style without a FontWeight setter keeps the default SemiBold."); + Verify.AreEqual(40.0, second.MinHeight, "Without a style MinHeight the header follows the density row height."); + + tableView.ColumnHeaderStyle = null; + }); + + IdleSynchronizer.Wait(); + + RunOnUIThread.Execute(() => + { + Verify.IsFalse(ReferenceEquals(tableBrush, RequireColumnHeader(tableView, 0).Background), + "Clearing ColumnHeaderStyle must restyle the realized headers."); + Verify.AreEqual((ushort)600, GetHeaderPresenter(tableView, 0).FontWeight.Weight, + "Clearing ColumnHeaderStyle must restore the default header weight."); + }); + } + + [TestMethod] + [TestProperty("Description", "Verifies header Styles that only inherit setters through BasedOn a ContentControl or Control Style apply, and their BasedOn Padding beats the density padding.")] + public void VerifyBasedOnOnlyColumnHeaderStyleApplies() + { + TableView tableView = null; + SolidColorBrush tableBrush = null; + SolidColorBrush columnBrush = null; + var stylePadding = new Thickness(20, 1, 20, 1); + bool loaded = false; + + // A checked build can stop on a framework assert while loading; fail instead of stalling. + using var watchdog = new global::System.Threading.Timer(_ => + { + if (!global::System.Threading.Volatile.Read(ref loaded)) + { + Environment.FailFast("VerifyBasedOnOnlyColumnHeaderStyleApplies: the TableView did not load within 60 seconds."); + } + }, null, TimeSpan.FromSeconds(60), global::System.Threading.Timeout.InfiniteTimeSpan); + + RunOnUIThread.Execute(() => + { + tableBrush = new SolidColorBrush(Microsoft.UI.Colors.Red); + columnBrush = new SolidColorBrush(Microsoft.UI.Colors.Green); + var tableBase = new Style(typeof(ContentControl)); + tableBase.Setters.Add(new Setter(Control.BackgroundProperty, tableBrush)); + tableBase.Setters.Add(new Setter(Control.PaddingProperty, stylePadding)); + var columnBase = new Style(typeof(Control)); + columnBase.Setters.Add(new Setter(Control.BackgroundProperty, columnBrush)); + + tableView = CreateThemingTable(); + tableView.ColumnHeaderStyle = new Style(typeof(TableViewColumnHeader)) { BasedOn = tableBase }; + tableView.Columns[1].HeaderStyle = new Style(typeof(TableViewColumnHeader)) { BasedOn = columnBase }; + LoadContent(tableView); + }); + IdleSynchronizer.Wait(); + global::System.Threading.Volatile.Write(ref loaded, true); + + RunOnUIThread.Execute(() => tableView.Density = TableViewDensity.Compact); + IdleSynchronizer.Wait(); + + RunOnUIThread.Execute(() => + { + var first = RequireColumnHeader(tableView, 0); + Verify.IsTrue(ReferenceEquals(tableBrush, first.Background), "A BasedOn Background setter must reach the header."); + Verify.AreEqual(stylePadding, first.Padding, "A BasedOn Padding must win over the density padding."); + + var second = RequireColumnHeader(tableView, 1); + Verify.IsTrue(ReferenceEquals(columnBrush, second.Background), "TableViewColumn.HeaderStyle's BasedOn Background must win over ColumnHeaderStyle."); + Verify.AreEqual(30.0, second.MinHeight, "Without a style MinHeight the header follows the Compact row height."); + }); + } + + [TestMethod] + [TestProperty("Description", "Verifies a UIElement column Header moves to the new header when the header band is rebuilt.")] + public void VerifyUIElementHeaderSurvivesHeaderRebuild() + { + TableView tableView = null; + Border headerElement = null; + + RunOnUIThread.Execute(() => + { + tableView = CreateThemingTable(); + headerElement = new Border { Width = 20, Height = 12, Background = new SolidColorBrush(Microsoft.UI.Colors.Blue) }; + tableView.Columns[0].Header = headerElement; + LoadContent(tableView); + }); + IdleSynchronizer.Wait(); + + RunOnUIThread.Execute(() => tableView.ColumnHeaderStyle = new Style(typeof(TableViewColumnHeader))); + IdleSynchronizer.Wait(); + RunOnUIThread.Execute(() => tableView.Density = TableViewDensity.Compact); + IdleSynchronizer.Wait(); + + RunOnUIThread.Execute(() => + { + var header = RequireColumnHeader(tableView, 0); + Verify.IsTrue(ReferenceEquals(headerElement, header.Content), "The rebuilt header must show the column's UIElement Header."); + + DependencyObject current = headerElement; + while (current != null && !ReferenceEquals(current, header)) + { + current = VisualTreeHelper.GetParent(current); + } + Verify.IsNotNull(current, "The UIElement Header must be parented under the rebuilt header."); + Verify.AreEqual(20.0, headerElement.ActualWidth, "The UIElement Header must be laid out in the rebuilt header."); + }); + } + + [TestMethod] + [TestProperty("Description", "Verifies a UIElement column Header moves to the new header band when the TableView is re-templated.")] + public void VerifyUIElementHeaderSurvivesRetemplate() + { + TableView tableView = null; + Border headerElement = null; + + RunOnUIThread.Execute(() => + { + tableView = CreateThemingTable(); + headerElement = new Border { Width = 20, Height = 12, Background = new SolidColorBrush(Microsoft.UI.Colors.Blue) }; + tableView.Columns[0].Header = headerElement; + LoadContent(tableView); + }); + IdleSynchronizer.Wait(); + + RunOnUIThread.Execute(() => + { + tableView.Template = (ControlTemplate)Microsoft.UI.Xaml.Markup.XamlReader.Load( + @" + + + + + + + + + + + + + + + "); + tableView.UpdateLayout(); + }); + IdleSynchronizer.Wait(); + + RunOnUIThread.Execute(() => VerifyHostedUnderHeader(tableView, 0, headerElement, "after re-templating")); + } + + [TestMethod] + [TestProperty("Description", "Verifies a column with a UIElement Header can move from one TableView to another.")] + public void VerifyUIElementHeaderSurvivesMoveToAnotherTableView() + { + TableView source = null; + TableView target = null; + StackPanel host = null; + Border headerElement = null; + + RunOnUIThread.Execute(() => + { + source = CreateThemingTable(); + headerElement = new Border { Width = 20, Height = 12, Background = new SolidColorBrush(Microsoft.UI.Colors.Blue) }; + source.Columns[0].Header = headerElement; + host = new StackPanel(); + host.Children.Add(source); + LoadContent(host); + }); + IdleSynchronizer.Wait(); + + RunOnUIThread.Execute(() => + { + var column = source.Columns[0]; + source.Columns.RemoveAt(0); + target = new TableView(); + target.Columns.Add(column); + // Templating the target rebuilds its headers before the source's queued rebuild runs. + host.Children.Add(target); + host.UpdateLayout(); + }); + IdleSynchronizer.Wait(); + + RunOnUIThread.Execute(() => VerifyHostedUnderHeader(target, 0, headerElement, "after the move")); + } + + [TestMethod] + [TestProperty("Description", "Verifies focus inside a UIElement column Header returns to that header after the header band is rebuilt.")] + public void VerifyFocusInUIElementHeaderIsRestoredAfterRebuild() + { + TableView tableView = null; + CheckBox headerElement = null; + + RunOnUIThread.Execute(() => + { + tableView = CreateThemingTable(); + headerElement = new CheckBox { MinWidth = 0 }; + tableView.Columns[0].Header = headerElement; + LoadContent(tableView); + }); + IdleSynchronizer.Wait(); + + RunOnUIThread.Execute(() => Verify.IsTrue(headerElement.Focus(FocusState.Keyboard), "Precondition: the header CheckBox takes focus.")); + IdleSynchronizer.Wait(); + + RunOnUIThread.Execute(() => tableView.Density = TableViewDensity.Compact); + IdleSynchronizer.Wait(); + + RunOnUIThread.Execute(() => + { + var header = RequireColumnHeader(tableView, 0); + DependencyObject current = FocusManager.GetFocusedElement(tableView.XamlRoot) as DependencyObject; + Log.Comment($"Focused after rebuild: {current}"); + while (current != null && !ReferenceEquals(current, header)) + { + current = VisualTreeHelper.GetParent(current); + } + Verify.IsNotNull(current, "Focus must return to the rebuilt header of the column that held it."); + }); + } + + [TestMethod] + [TestProperty("Description", "Verifies a replaced UIElement column Header can be re-parented right away, before the header band rebuilds.")] + public void VerifyReplacedUIElementHeaderCanBeReparentedImmediately() + { + TableView tableView = null; + StackPanel root = null; + Border headerElement = null; + + RunOnUIThread.Execute(() => + { + tableView = CreateThemingTable(); + headerElement = new Border { Width = 20, Height = 12, Background = new SolidColorBrush(Microsoft.UI.Colors.Blue) }; + tableView.Columns[0].Header = headerElement; + root = new StackPanel(); + root.Children.Add(tableView); + LoadContent(root); + }); + IdleSynchronizer.Wait(); + + RunOnUIThread.Execute(() => + { + tableView.Columns[0].Header = "Name"; + var other = new StackPanel(); + root.Children.Add(other); + other.Children.Add(headerElement); + Verify.IsTrue(ReferenceEquals(other, VisualTreeHelper.GetParent(headerElement)), "The old Header must move to the new panel."); + }); + IdleSynchronizer.Wait(); + + RunOnUIThread.Execute(() => Verify.AreEqual("Name", (RequireColumnHeader(tableView, 0).Content as TextBlock)?.Text, + "The header band must show the new Header.")); + } + + [TestMethod] + [TestProperty("Description", "Verifies focus inside a UIElement column Header follows the column when it moves within the same TableView.")] + public void VerifyFocusInUIElementHeaderFollowsColumnMove() + { + TableView tableView = null; + CheckBox headerElement = null; + + RunOnUIThread.Execute(() => + { + tableView = CreateThemingTable(); + headerElement = new CheckBox { MinWidth = 0 }; + tableView.Columns[0].Header = headerElement; + LoadContent(tableView); + }); + IdleSynchronizer.Wait(); + + RunOnUIThread.Execute(() => Verify.IsTrue(headerElement.Focus(FocusState.Keyboard), "Precondition: the header CheckBox takes focus.")); + IdleSynchronizer.Wait(); + + RunOnUIThread.Execute(() => + { + var column = tableView.Columns[0]; + tableView.Columns.RemoveAt(0); + tableView.Columns.Insert(1, column); + }); + IdleSynchronizer.Wait(); + + RunOnUIThread.Execute(() => + { + var header = RequireColumnHeader(tableView, 1); + Verify.IsTrue(ReferenceEquals(headerElement, header.Content), "Precondition: the moved column's header hosts its Header."); + DependencyObject current = FocusManager.GetFocusedElement(tableView.XamlRoot) as DependencyObject; + Log.Comment($"Focused after the move: {current}"); + while (current != null && !ReferenceEquals(current, header)) + { + current = VisualTreeHelper.GetParent(current); + } + Verify.IsNotNull(current, "Focus must follow the moved column to its rebuilt header."); + }); + } + + [TestMethod] + [TestProperty("Description", "Verifies removing a column whose UIElement Header holds focus moves focus to the nearest remaining header, never onto the removed one.")] + public void VerifyFocusInRemovedColumnHeaderMovesToNearestHeader() + { + TableView tableView = null; + CheckBox headerElement = null; + var removedHeaderFocused = false; + + RunOnUIThread.Execute(() => + { + tableView = CreateThemingTable(); + headerElement = new CheckBox { MinWidth = 0 }; + tableView.Columns[0].Header = headerElement; + LoadContent(tableView); + }); + IdleSynchronizer.Wait(); + + RunOnUIThread.Execute(() => Verify.IsTrue(headerElement.Focus(FocusState.Keyboard), "Precondition: the header CheckBox takes focus.")); + IdleSynchronizer.Wait(); + + RunOnUIThread.Execute(() => + { + var removedHeader = RequireColumnHeader(tableView, 0); + EventHandler handler = (s, args) => + { + if (ReferenceEquals(args.NewFocusedElement, removedHeader)) + { + removedHeaderFocused = true; + } + }; + FocusManager.GettingFocus += handler; + try + { + tableView.Columns.RemoveAt(0); + } + finally + { + FocusManager.GettingFocus -= handler; + } + Verify.IsFalse(removedHeaderFocused, "Focus must not move onto the removed column's header."); + }); + IdleSynchronizer.Wait(); + + RunOnUIThread.Execute(() => + { + var header = RequireColumnHeader(tableView, 0); + DependencyObject focused = FocusManager.GetFocusedElement(tableView.XamlRoot) as DependencyObject; + Log.Comment($"Focused after the removal: {focused}"); + Verify.IsTrue(focused != null && IsUnder(focused, header), "Focus must move to the nearest remaining header."); + }); + } + + [TestMethod] + [TestProperty("Description", "Verifies replacing the Columns vector while a header holds keyboard focus keeps focus on a header of the table.")] + public void VerifyHeaderFocusStaysInHeaderBandWhenColumnsVectorReplaced() + { + TableView tableView = null; + + RunOnUIThread.Execute(() => + { + tableView = CreateThemingTable(); + LoadContent(tableView); + }); + IdleSynchronizer.Wait(); + + RunOnUIThread.Execute(() => Verify.IsTrue(RequireColumnHeader(tableView, 1).Focus(FocusState.Keyboard), "Precondition: the second header takes focus.")); + IdleSynchronizer.Wait(); + + RunOnUIThread.Execute(() => + { + // A vector no table is subscribed to, holding columns no table owns. + var donor = new TableView(); + var replacement = donor.GetValue(TableView.ColumnsProperty); + donor.SetValue(TableView.ColumnsProperty, new TableView().GetValue(TableView.ColumnsProperty)); + var columns = (IList)replacement; + columns.Add(MakeTextColumn("Name")); + columns.Add(MakeTextColumn("Role")); + + tableView.SetValue(TableView.ColumnsProperty, replacement); + }); + IdleSynchronizer.Wait(); + + RunOnUIThread.Execute(() => + { + var focused = FocusManager.GetFocusedElement(tableView.XamlRoot) as DependencyObject; + Log.Comment($"Focused after the replacement: {focused}"); + var header = FindVisualChildrenByType(tableView).FirstOrDefault(h => focused != null && IsUnder(focused, h)); + Verify.IsNotNull(header, "Focus must stay on a header of the table."); + Verify.AreEqual(FocusState.Keyboard, (focused as UIElement)?.FocusState ?? FocusState.Unfocused, "Focus keeps its keyboard state."); + }); + } + + [TestMethod] + [TestProperty("Description", "Verifies focus in the header of a column that is moved or re-added with no other header to hold focus returns to that column's header.")] + [TestProperty("data:Change", "{MoveOnlyColumn, ClearAndReAddLast}")] + public void VerifyHeaderFocusReturnsWhenNoOtherHeaderCanHoldIt() + { + var change = ((string)TestContext.DataRow["Change"]).Trim(); + var focusedIndex = change == "ClearAndReAddLast" ? 1 : 0; + TableView tableView = null; + CheckBox headerElement = null; + + RunOnUIThread.Execute(() => + { + tableView = change == "MoveOnlyColumn" ? CreateTableView(headers: new[] { "Name" }) : CreateThemingTable(); + headerElement = new CheckBox { MinWidth = 0 }; + tableView.Columns[focusedIndex].Header = headerElement; + LoadContent(tableView); + }); + IdleSynchronizer.Wait(); + + RunOnUIThread.Execute(() => Verify.IsTrue(headerElement.Focus(FocusState.Keyboard), "Precondition: the header CheckBox takes focus.")); + IdleSynchronizer.Wait(); + + RunOnUIThread.Execute(() => + { + var columns = tableView.Columns.ToList(); + if (change == "MoveOnlyColumn") + { + tableView.Columns.RemoveAt(0); + tableView.Columns.Insert(0, columns[0]); + } + else + { + tableView.Columns.Clear(); + foreach (var column in columns) + { + tableView.Columns.Add(column); + } + } + }); + IdleSynchronizer.Wait(); + + RunOnUIThread.Execute(() => + { + var header = RequireColumnHeader(tableView, focusedIndex); + var focused = FocusManager.GetFocusedElement(tableView.XamlRoot) as DependencyObject; + Log.Comment($"{change}: focused {focused}"); + Verify.IsTrue(focused != null && IsUnder(focused, header), $"{change}: focus must return to the column's header."); + Verify.AreEqual(FocusState.Keyboard, ((UIElement)focused).FocusState, $"{change}: keyboard focus must stay keyboard focus."); + }); + } + + [TestMethod] + [TestProperty("Description", "Verifies focus in the header of the only column moves into the body when that column is removed.")] + public void VerifyFocusInOnlyRemovedColumnHeaderMovesToBody() + { + TableView tableView = null; + CheckBox headerElement = null; + + RunOnUIThread.Execute(() => + { + tableView = CreateTableView(headers: new[] { "Name" }); + headerElement = new CheckBox { MinWidth = 0 }; + tableView.Columns[0].Header = headerElement; + LoadContent(tableView); + }); + IdleSynchronizer.Wait(); + + RunOnUIThread.Execute(() => Verify.IsTrue(headerElement.Focus(FocusState.Keyboard), "Precondition: the header CheckBox takes focus.")); + IdleSynchronizer.Wait(); + + RunOnUIThread.Execute(() => tableView.Columns.RemoveAt(0)); + IdleSynchronizer.Wait(); + + RunOnUIThread.Execute(() => + { + var focused = FocusManager.GetFocusedElement(tableView.XamlRoot) as DependencyObject; + Log.Comment($"Focused after the removal: {focused}"); + Verify.IsTrue(focused != null && IsUnder(focused, GetRowsRepeater(tableView)), "Focus must move into the table body."); + }); + } + + [TestMethod] + [TestProperty("Description", "Verifies focus in a header stays in the header band when the columns are replaced by new instances.")] + [TestProperty("data:Change", "{ClearAndAddNew, ReplaceOnlyColumnWithoutRows}")] + public void VerifyHeaderFocusStaysInHeaderBandWhenColumnsReplaced() + { + var change = ((string)TestContext.DataRow["Change"]).Trim(); + TableView tableView = null; + CheckBox headerElement = null; + + RunOnUIThread.Execute(() => + { + tableView = change == "ReplaceOnlyColumnWithoutRows" + ? CreateTableView(new List(), headers: new[] { "Name" }) + : CreateThemingTable(); + headerElement = new CheckBox { MinWidth = 0 }; + tableView.Columns[0].Header = headerElement; + LoadContent(tableView); + }); + IdleSynchronizer.Wait(); + + RunOnUIThread.Execute(() => Verify.IsTrue(headerElement.Focus(FocusState.Keyboard), "Precondition: the header CheckBox takes focus.")); + IdleSynchronizer.Wait(); + + RunOnUIThread.Execute(() => + { + if (change == "ClearAndAddNew") + { + tableView.Columns.Clear(); + tableView.Columns.Add(MakeTextColumn("Name")); + tableView.Columns.Add(MakeTextColumn("Role")); + } + else + { + tableView.Columns[0] = MakeTextColumn("Name"); + } + }); + IdleSynchronizer.Wait(); + + RunOnUIThread.Execute(() => + { + var focused = FocusManager.GetFocusedElement(tableView.XamlRoot) as DependencyObject; + Log.Comment($"{change}: focused {focused}"); + Verify.IsTrue(focused != null && IsUnder(focused, RequireColumnHeader(tableView, 0)), $"{change}: focus must move to the first header of the new columns."); + Verify.AreEqual(FocusState.Keyboard, ((UIElement)focused).FocusState, $"{change}: keyboard focus must stay keyboard focus."); + }); + } + + + [TestMethod] + [TestProperty("Description", "Verifies changing a string Header keeps the header's content and tooltip until the header band rebuilds.")] + public void VerifyStringHeaderChangeKeepsHeaderContentUntilRebuild() + { + TableView tableView = null; + + RunOnUIThread.Execute(() => + { + tableView = CreateThemingTable(); + tableView.Columns[0].HeaderToolTip = "Tip"; + LoadContent(tableView); + }); + IdleSynchronizer.Wait(); + + RunOnUIThread.Execute(() => + { + var header = RequireColumnHeader(tableView, 0); + Verify.IsNotNull(ToolTipService.GetToolTip(header), "Precondition: the header has a tooltip."); + tableView.Columns[0].Header = "Renamed"; + Verify.IsNotNull(header.Content, "A string Header change must not blank the header before the rebuild."); + Verify.IsNotNull(ToolTipService.GetToolTip(header), "A string Header change must not drop the tooltip before the rebuild."); + }); + IdleSynchronizer.Wait(); + + RunOnUIThread.Execute(() => Verify.AreEqual("Renamed", (RequireColumnHeader(tableView, 0).Content as TextBlock)?.Text, + "The header band must show the new Header.")); + } + + [TestMethod] + [TestProperty("Description", "Verifies a header rebuild started from app code while another rebuild releases headers keeps UIElement Headers hosted.")] + public void VerifyReentrantHeaderRebuildKeepsUIElementHeader() + { + TableView tableView = null; + CheckBox headerElement = null; + bool reentered = false; + + RunOnUIThread.Execute(() => + { + tableView = CreateThemingTable(); + headerElement = new CheckBox { MinWidth = 0 }; + tableView.Columns[0].Header = headerElement; + LoadContent(tableView); + }); + IdleSynchronizer.Wait(); + + RunOnUIThread.Execute(() => Verify.IsTrue(headerElement.Focus(FocusState.Keyboard), "Precondition: the header CheckBox takes focus.")); + IdleSynchronizer.Wait(); + + RunOnUIThread.Execute(() => + { + EventHandler handler = (s, args) => + { + if (!reentered && ReferenceEquals(args.OldFocusedElement, headerElement)) + { + reentered = true; + // Rebuilds the header band synchronously, from inside the outer rebuild. + tableView.CanUserResizeColumns = !tableView.CanUserResizeColumns; + } + }; + FocusManager.GettingFocus += handler; + try + { + tableView.Density = TableViewDensity.Compact; + } + finally + { + FocusManager.GettingFocus -= handler; + } + Verify.IsTrue(reentered, "Precondition: releasing the focused Header re-entered the rebuild."); + }); + IdleSynchronizer.Wait(); + + RunOnUIThread.Execute(() => + { + var header = RequireColumnHeader(tableView, 0); + Verify.IsTrue(ReferenceEquals(headerElement, header.Content), "The header must show the column's UIElement Header."); + Verify.IsTrue(IsUnder(headerElement, header), "The UIElement Header must be parented under the rebuilt header."); + }); + } + + private static bool IsUnder(DependencyObject element, DependencyObject ancestor) + { + for (var current = element; current != null; current = VisualTreeHelper.GetParent(current)) + { + if (ReferenceEquals(current, ancestor)) + { + return true; + } + } + return false; + } + + private static void VerifyHostedUnderHeader(TableView tableView, int index, FrameworkElement headerElement, string when) + { + var header = RequireColumnHeader(tableView, index); + Verify.IsTrue(ReferenceEquals(headerElement, header.Content), $"The header must show the column's UIElement Header {when}."); + + DependencyObject current = headerElement; + while (current != null && !ReferenceEquals(current, header)) + { + current = VisualTreeHelper.GetParent(current); + } + Verify.IsNotNull(current, $"The UIElement Header must be parented under the header {when}."); + Verify.AreEqual(20.0, headerElement.ActualWidth, $"The UIElement Header must be laid out {when}."); + } + + [TestMethod] + [TestProperty("Description", "Verifies a disabled TableView puts its headers in Disabled, dimming the header text and sort glyph.")] + public void VerifyDisabledHeaderDimsText() + { + var disabled = Color.FromArgb(0xFF, 0x20, 0x40, 0x60); + TableView tableView = null; + + RunOnUIThread.Execute(() => + { + EnsureTabularControlsResources(); + SetApplicationResource("TabularSurfaceRowForegroundDisabledBrush", new SolidColorBrush(disabled)); + tableView = CreateThemingTable(); + tableView.Columns[0].CanSort = true; + LoadContent(tableView); + }); + IdleSynchronizer.Wait(); + + try + { + RunOnUIThread.Execute(() => + { + Verify.IsTrue(tableView.SortByColumn(tableView.Columns[0], SortDirection.Ascending), "Precondition: the column sorts."); + tableView.IsEnabled = false; + }); + IdleSynchronizer.Wait(); + + RunOnUIThread.Execute(() => + { + var header = RequireColumnHeader(tableView, 0); + Verify.AreEqual("Disabled", CurrentState(header, "CommonStates"), "A disabled table's header must be Disabled."); + Verify.AreEqual(disabled, (GetHeaderPresenter(tableView, 0).Foreground as SolidColorBrush)?.Color ?? default, + "Disabled must dim the header text."); + var glyph = header.FindVisualChildByName("TableViewSortIndicator") as Control; + Verify.IsNotNull(glyph, "A sorted column must host the sort glyph."); + Verify.AreEqual(disabled, (glyph?.Foreground as SolidColorBrush)?.Color ?? default, "Disabled must dim the sort glyph."); + }); + } + finally + { + RunOnUIThread.Execute(() => ClearApplicationResource("TabularSurfaceRowForegroundDisabledBrush")); + } + } + + [TestMethod] + [TestProperty("Description", "Verifies disabling a header in its High Contrast hover state keeps the disabled text and glyph brush, and re-enabling drops it.")] + public void VerifyDisablingHighContrastHoveredHeaderKeepsDisabledForeground() + { + var disabled = Color.FromArgb(0xFF, 0x20, 0x40, 0x60); + TableView tableView = null; + TableViewColumnHeader header = null; + + RunOnUIThread.Execute(() => + { + EnsureTabularControlsResources(); + SetApplicationResource("TabularSurfaceRowForegroundDisabledBrush", new SolidColorBrush(disabled)); + tableView = CreateThemingTable(); + tableView.Columns[0].CanSort = true; + LoadContent(tableView); + }); + IdleSynchronizer.Wait(); + + try + { + RunOnUIThread.Execute(() => Verify.IsTrue(tableView.SortByColumn(tableView.Columns[0], SortDirection.Ascending), "Precondition: the column sorts.")); + IdleSynchronizer.Wait(); + + RunOnUIThread.Execute(() => + { + header = RequireColumnHeader(tableView, 0); + // The hovered High Contrast states, entered directly: a test cannot switch the system theme. + Verify.IsTrue(VisualStateManager.GoToState(header, "PointerOver", false), "PointerOver must exist."); + Verify.IsTrue(VisualStateManager.GoToState(header, "HighContrastPointerOver", false), "HighContrastPointerOver must exist."); + header.IsEnabled = false; + }); + IdleSynchronizer.Wait(); + + RunOnUIThread.Execute(() => + { + Verify.AreEqual("Disabled", CurrentState(header, "CommonStates"), "Precondition: the disabled header is Disabled."); + Verify.AreEqual("HighContrastNormal", CurrentState(header, "HighContrastStates"), "Precondition: the hover highlight is gone."); + Verify.AreEqual(disabled, (GetHeaderPresenter(tableView, 0).Foreground as SolidColorBrush)?.Color ?? default, + "Leaving the High Contrast hover state must not drop the disabled text brush."); + var glyph = header.FindVisualChildByName("TableViewSortIndicator") as Control; + Verify.IsNotNull(glyph, "A sorted column must host the sort glyph."); + Verify.AreEqual(disabled, (glyph?.Foreground as SolidColorBrush)?.Color ?? default, + "Leaving the High Contrast hover state must not drop the disabled glyph brush."); + header.IsEnabled = true; + }); + IdleSynchronizer.Wait(); + + RunOnUIThread.Execute(() => + { + Verify.AreEqual("Normal", CurrentState(header, "CommonStates"), "Precondition: the re-enabled header is Normal."); + Verify.AreNotEqual(disabled, (GetHeaderPresenter(tableView, 0).Foreground as SolidColorBrush)?.Color ?? default, + "Re-enabling must drop the disabled text brush."); + }); + } + finally + { + RunOnUIThread.Execute(() => ClearApplicationResource("TabularSurfaceRowForegroundDisabledBrush")); + } + } + + [TestMethod] + [TestProperty("Description", "Verifies a disabled header's sort glyph follows the header text when its theme brush re-resolves without a visual state change.")] + public void VerifyDisabledSortGlyphFollowsTextAcrossThemeChange() + { + TableView tableView = null; + TableViewColumnHeader header = null; + Color before = default; + + RunOnUIThread.Execute(() => + { + tableView = CreateThemingTable(); + tableView.RequestedTheme = ElementTheme.Light; + tableView.Columns[0].CanSort = true; + LoadContent(tableView); + }); + IdleSynchronizer.Wait(); + + RunOnUIThread.Execute(() => + { + Verify.IsTrue(tableView.SortByColumn(tableView.Columns[0], SortDirection.Ascending), "Precondition: the column sorts."); + tableView.IsEnabled = false; + }); + IdleSynchronizer.Wait(); + + RunOnUIThread.Execute(() => + { + header = RequireColumnHeader(tableView, 0); + Verify.AreEqual("Disabled", CurrentState(header, "CommonStates"), "Precondition: a disabled table's header is Disabled."); + before = (GetHeaderPresenter(tableView, 0).Foreground as SolidColorBrush)?.Color ?? default; + + // Re-resolves the header's ThemeResources without re-running its visual states, as a + // High Contrast toggle does. + header.RequestedTheme = ElementTheme.Dark; + }); + IdleSynchronizer.Wait(); + + RunOnUIThread.Execute(() => + { + var text = (GetHeaderPresenter(tableView, 0).Foreground as SolidColorBrush)?.Color ?? default; + Verify.AreNotEqual(before, text, "Precondition: the disabled text brush differs between Light and Dark."); + var glyph = header.FindVisualChildByName("TableViewSortIndicator") as Control; + Verify.IsNotNull(glyph, "A sorted column must host the sort glyph."); + Verify.AreEqual(text, (glyph?.Foreground as SolidColorBrush)?.Color ?? default, + "The disabled sort glyph must keep the header text's brush after it re-resolves."); + }); + } + + [TestMethod] + [TestProperty("Description", "Verifies a header Style with an incompatible TargetType is ignored instead of breaking the header band.")] + public void VerifyIncompatibleColumnHeaderStyleIsIgnored() + { + TableView tableView = null; + SolidColorBrush brush = null; + + RunOnUIThread.Execute(() => + { + brush = new SolidColorBrush(Microsoft.UI.Colors.Red); + var buttonStyle = new Style(typeof(Button)); + buttonStyle.Setters.Add(new Setter(Control.BackgroundProperty, brush)); + tableView = CreateThemingTable(); + tableView.ColumnHeaderStyle = buttonStyle; + tableView.Columns[1].HeaderStyle = buttonStyle; + LoadContent(tableView); + }); + IdleSynchronizer.Wait(); + + RunOnUIThread.Execute(() => + { + foreach (var index in new[] { 0, 1 }) + { + var header = RequireColumnHeader(tableView, index); + Verify.IsNull(header.Style, $"Header {index} must not take a Style targeting another type."); + Verify.IsFalse(ReferenceEquals(brush, header.Background), $"Header {index} must keep its default look."); + } + }); + } + + [TestMethod] + [TestProperty("Description", "Verifies a header Style whose BasedOn targets an incompatible type is ignored instead of breaking the header band.")] + public void VerifyColumnHeaderStyleWithIncompatibleBasedOnIsIgnored() + { + TableView tableView = null; + SolidColorBrush brush = null; + + RunOnUIThread.Execute(() => + { + brush = new SolidColorBrush(Microsoft.UI.Colors.Red); + var headerStyle = new Style(typeof(TableViewColumnHeader)) { BasedOn = new Style(typeof(Button)) }; + headerStyle.Setters.Add(new Setter(Control.BackgroundProperty, brush)); + tableView = CreateThemingTable(); + tableView.ColumnHeaderStyle = headerStyle; + LoadContent(tableView); + }); + IdleSynchronizer.Wait(); + + RunOnUIThread.Execute(() => + { + foreach (var index in new[] { 0, 1 }) + { + var header = RequireColumnHeader(tableView, index); + Verify.IsNull(header.Style, $"Header {index} must not take a Style based on another type."); + Verify.IsFalse(ReferenceEquals(brush, header.Background), $"Header {index} must keep its default look."); + } + }); + } + + [TestMethod] + [TestProperty("Description", "Verifies a header Style whose BasedOn chain loops is ignored instead of breaking the header band.")] + public void VerifyColumnHeaderStyleWithBasedOnCycleIsIgnored() + { + TableView tableView = null; + SolidColorBrush brush = null; + + RunOnUIThread.Execute(() => + { + brush = new SolidColorBrush(Microsoft.UI.Colors.Red); + var first = new Style(typeof(TableViewColumnHeader)); + var second = new Style(typeof(TableViewColumnHeader)) { BasedOn = first }; + first.BasedOn = second; + first.Setters.Add(new Setter(Control.BackgroundProperty, brush)); + tableView = CreateThemingTable(); + tableView.ColumnHeaderStyle = first; + LoadContent(tableView); + }); + IdleSynchronizer.Wait(); + + RunOnUIThread.Execute(() => + { + foreach (var index in new[] { 0, 1 }) + { + var header = RequireColumnHeader(tableView, index); + Verify.IsNull(header.Style, $"Header {index} must not take a Style whose BasedOn chain loops."); + } + }); + } + + [TestMethod] + [TestProperty("Description", "Verifies a header Style without a TargetType, alone or as a BasedOn, is ignored instead of breaking the header band.")] + public void VerifyUntypedColumnHeaderStyleIsIgnored() + { + TableView tableView = null; + SolidColorBrush tableBrush = null; + SolidColorBrush columnBrush = null; + + RunOnUIThread.Execute(() => + { + tableBrush = new SolidColorBrush(Microsoft.UI.Colors.Red); + columnBrush = new SolidColorBrush(Microsoft.UI.Colors.Green); + var untyped = new Style(); + untyped.Setters.Add(new Setter(Control.BackgroundProperty, tableBrush)); + var untypedBase = new Style(); + untypedBase.Setters.Add(new Setter(Control.BackgroundProperty, columnBrush)); + tableView = CreateThemingTable(); + tableView.ColumnHeaderStyle = untyped; + tableView.Columns[1].HeaderStyle = new Style(typeof(TableViewColumnHeader)) { BasedOn = untypedBase }; + LoadContent(tableView); + }); + IdleSynchronizer.Wait(); + + RunOnUIThread.Execute(() => + { + foreach (var index in new[] { 0, 1 }) + { + var header = RequireColumnHeader(tableView, index); + Verify.IsNull(header.Style, $"Header {index} must not take a Style chain without a TargetType."); + Verify.IsFalse(ReferenceEquals(tableBrush, header.Background) || ReferenceEquals(columnBrush, header.Background), + $"Header {index} must keep its default look."); + } + }); + } + + [TestMethod] + [TestProperty("Description", "Verifies header Styles targeting TableViewColumnHeader's framework base types are applied.")] + public void VerifyColumnHeaderStyleTargetingBaseTypesApplies() + { + TableView tableView = null; + Style tableStyle = null; + Style columnStyle = null; + SolidColorBrush tableBrush = null; + SolidColorBrush columnBrush = null; + + RunOnUIThread.Execute(() => + { + tableBrush = new SolidColorBrush(Microsoft.UI.Colors.Red); + columnBrush = new SolidColorBrush(Microsoft.UI.Colors.Green); + // Framework types: their TargetType names come back as freshly allocated strings. + tableStyle = new Style(typeof(ContentControl)); + tableStyle.Setters.Add(new Setter(Control.BackgroundProperty, tableBrush)); + columnStyle = new Style(typeof(Control)); + columnStyle.Setters.Add(new Setter(Control.BackgroundProperty, columnBrush)); + tableView = CreateThemingTable(); + tableView.ColumnHeaderStyle = tableStyle; + tableView.Columns[1].HeaderStyle = columnStyle; + LoadContent(tableView); + }); + IdleSynchronizer.Wait(); + + RunOnUIThread.Execute(() => + { + var first = RequireColumnHeader(tableView, 0); + Verify.IsTrue(ReferenceEquals(tableStyle, first.Style), "A ContentControl Style must apply to the header."); + Verify.IsTrue(ReferenceEquals(tableBrush, first.Background), "The ContentControl Style's setter must reach the header."); + var second = RequireColumnHeader(tableView, 1); + Verify.IsTrue(ReferenceEquals(columnStyle, second.Style), "A Control Style must apply to the header."); + Verify.IsTrue(ReferenceEquals(columnBrush, second.Background), "The Control Style's setter must reach the header."); + }); + } + + [TestMethod] + [TestProperty("Description", "Verifies a disabled header keeps one glyph Foreground binding across visual state updates instead of rebinding each time.")] + public void VerifyDisabledSortGlyphBindingIsNotRecreated() + { + TableView tableView = null; + Control glyph = null; + BindingExpression binding = null; + + RunOnUIThread.Execute(() => + { + tableView = CreateThemingTable(); + tableView.Columns[0].CanSort = true; + LoadContent(tableView); + }); + IdleSynchronizer.Wait(); + + RunOnUIThread.Execute(() => + { + Verify.IsTrue(tableView.SortByColumn(tableView.Columns[0], SortDirection.Ascending), "Precondition: the column sorts."); + tableView.IsEnabled = false; + }); + IdleSynchronizer.Wait(); + + RunOnUIThread.Execute(() => + { + glyph = RequireColumnHeader(tableView, 0).FindVisualChildByName("TableViewSortIndicator") as Control; + Verify.IsNotNull(glyph, "Precondition: a sorted column hosts the sort glyph."); + binding = glyph.GetBindingExpression(Control.ForegroundProperty); + Verify.IsNotNull(binding, "Precondition: the disabled glyph is bound to the header text."); + Verify.IsTrue(tableView.SortByColumn(tableView.Columns[0], SortDirection.Descending), "Precondition: the sort direction changes."); + }); + IdleSynchronizer.Wait(); + + RunOnUIThread.Execute(() => + { + Verify.IsTrue(ReferenceEquals(glyph, RequireColumnHeader(tableView, 0).FindVisualChildByName("TableViewSortIndicator")), + "Precondition: the sort change updates the realized header in place."); + Verify.IsTrue(ReferenceEquals(binding, glyph.GetBindingExpression(Control.ForegroundProperty)), + "A state update must keep the glyph's existing binding."); + }); + } + + [TestMethod] + [TestProperty("Description", "Verifies the default header template provides its parts and hosts the sort glyph and resize gripper in them.")] + public void VerifyColumnHeaderTemplateParts() + { + TableView tableView = null; + + RunOnUIThread.Execute(() => + { + tableView = CreateThemingTable(); + LoadContent(tableView); + }); + + IdleSynchronizer.Wait(); + + RunOnUIThread.Execute(() => + { + var header = RequireColumnHeader(tableView, 0); + var presenter = header.FindVisualChildByName("PART_ContentPresenter") as ContentPresenter; + Verify.IsNotNull(presenter, "PART_ContentPresenter should exist."); + Verify.IsTrue(ReferenceEquals(presenter, GetHeaderPresenter(tableView, 0)), "The content presenter should be the header's first ContentPresenter."); + Verify.IsNotNull(header.FindVisualChildByName("PART_GridLine") as Border, "PART_GridLine should exist."); + + var indicatorHost = header.FindVisualChildByName("PART_SortIndicatorHost") as Border; + Verify.IsNotNull(indicatorHost, "PART_SortIndicatorHost should exist."); + Verify.IsTrue(indicatorHost.Child is SortIndicator, "A sortable header should host its sort glyph in PART_SortIndicatorHost."); + + var gripperHost = header.FindVisualChildByName("PART_ResizeGripperHost") as Border; + Verify.IsNotNull(gripperHost, "PART_ResizeGripperHost should exist."); + Verify.IsTrue(gripperHost.Child is ResizeGripper, "A resizable header should host its gripper in PART_ResizeGripperHost."); + }); + } + + [TestMethod] + [TestProperty("Description", "Verifies SortStates follow the column's sort direction and show the glyph only while sorted.")] + public void VerifyColumnHeaderSortStates() + { + TableView tableView = null; + + RunOnUIThread.Execute(() => + { + tableView = CreateThemingTable(); + LoadContent(tableView); + }); + + IdleSynchronizer.Wait(); + + void VerifyState(string expected, Visibility hostVisibility, string context) + { + RunOnUIThread.Execute(() => + { + var header = RequireColumnHeader(tableView, 0); + Verify.AreEqual(expected, CurrentState(header, "SortStates"), $"SortStates ({context})."); + var host = (UIElement)header.FindVisualChildByName("PART_SortIndicatorHost"); + Verify.AreEqual(hostVisibility, host.Visibility, $"Sort glyph visibility ({context})."); + Verify.AreEqual("Unsorted", CurrentState(RequireColumnHeader(tableView, 1), "SortStates"), + $"The other column stays Unsorted ({context})."); + }); + } + + VerifyState("Unsorted", Visibility.Collapsed, "initial"); + + RunOnUIThread.Execute(() => Verify.IsTrue(tableView.SortByColumn(tableView.Columns[0], SortDirection.Ascending))); + IdleSynchronizer.Wait(); + VerifyState("SortAscending", Visibility.Visible, "ascending"); + + RunOnUIThread.Execute(() => Verify.IsTrue(tableView.SortByColumn(tableView.Columns[0], SortDirection.Descending))); + IdleSynchronizer.Wait(); + VerifyState("SortDescending", Visibility.Visible, "descending"); + + RunOnUIThread.Execute(() => Verify.IsTrue(tableView.ClearSort())); + IdleSynchronizer.Wait(); + VerifyState("Unsorted", Visibility.Collapsed, "cleared"); + } + + [TestMethod] + [TestProperty("Description", "Verifies header typography comes from the header Style, not from local values written by the control.")] + public void VerifyColumnHeaderTypographyHasNoLocalValues() + { + TableView tableView = null; + + RunOnUIThread.Execute(() => + { + tableView = CreateThemingTable(); + LoadContent(tableView); + }); + + IdleSynchronizer.Wait(); + + RunOnUIThread.Execute(() => + { + var header = RequireColumnHeader(tableView, 0); + var presenter = GetHeaderPresenter(tableView, 0); + var text = presenter.Content as TextBlock; + Verify.IsNotNull(text, "A plain string header should render through a TextBlock."); + + foreach (var (element, fontSize, fontWeight, name) in new (DependencyObject, DependencyProperty, DependencyProperty, string)[] + { + (header, Control.FontSizeProperty, Control.FontWeightProperty, "header"), + (presenter, ContentPresenter.FontSizeProperty, ContentPresenter.FontWeightProperty, "content presenter"), + (text, TextBlock.FontSizeProperty, TextBlock.FontWeightProperty, "header text"), + }) + { + Verify.AreEqual(DependencyProperty.UnsetValue, element.ReadLocalValue(fontSize), $"The {name} must not carry a local FontSize."); + Verify.AreEqual(DependencyProperty.UnsetValue, element.ReadLocalValue(fontWeight), $"The {name} must not carry a local FontWeight."); + } + + Verify.AreEqual((ushort)600, text.FontWeight.Weight, "The default header style keeps SemiBold."); + Verify.AreEqual(14.0, text.FontSize, "The default header style keeps TableViewHeaderFontSize."); + }); + } + + #endregion + #region 13.6 Row banding under an element theme // The subtree runs opposite to the app theme so an Application-theme resolution is detectable. diff --git a/controls/dev/TableView/APITests/TableView_ToolTips_APITests.cs b/controls/dev/TableView/APITests/TableView_ToolTips_APITests.cs index 0b99a438b..1711a951c 100644 --- a/controls/dev/TableView/APITests/TableView_ToolTips_APITests.cs +++ b/controls/dev/TableView/APITests/TableView_ToolTips_APITests.cs @@ -324,6 +324,45 @@ namespace Microsoft.UI.Xaml.Tests.MUXControls.ApiTests }); } + [TestMethod] + [TestProperty("Description", "Verifies recycled rows keep their tooltip and help text when the bound value does not change between items.")] + public void VerifyRecycledRowKeepsToolTipWhenItemsShareTheValue() + { + TableView tableView = null; + var items = MakeManyItems(200); + + RunOnUIThread.Execute(() => + { + tableView = CreateToolTipTable(items, cellToolTipPath: null); + // An explicit Source keeps the value constant across the DataContext reset, so a + // recycled cell gets no property-changed callback to revive its tooltip. + tableView.Columns[0].CellToolTipBinding = new Binding { Source = "Shared role", Mode = BindingMode.OneWay }; + LoadContent(tableView); + }); + + IdleSynchronizer.Wait(); + + ScrollBodyTo(tableView, ScrollAxis.Vertical, 2000.0); + ScrollBodyTo(tableView, ScrollAxis.Vertical, 4000.0); + + RunOnUIThread.Execute(() => + { + var rows = GetRealizedRows(tableView); + Verify.IsGreaterThan(rows.Count, 0, "Rows must be realized after the scroll."); + + foreach (var row in rows) + { + var item = row.DataContext as Person; + var wrapper = GetRowCell(row, 0); + var toolTip = RequireToolTip(wrapper, $"the row for {item?.Name}"); + Verify.AreEqual("Shared role", toolTip.Content as string, "A recycled row must keep its tooltip content."); + Verify.IsTrue(toolTip.IsEnabled, "A recycled row's tooltip must be enabled."); + Verify.AreEqual("Shared role", AutomationProperties.GetHelpText(wrapper), + "A recycled row must re-publish the tooltip as help text."); + } + }); + } + [TestMethod] [TestProperty("Description", "Verifies a source PropertyChanged updates a live cell tooltip in place, on the same ToolTip instance.")] public void VerifyItemPropertyChangeUpdatesCellToolTipInPlace() diff --git a/controls/dev/TableView/InteractionTests/TableViewInteractionTestHelpers.cs b/controls/dev/TableView/InteractionTests/TableViewInteractionTestHelpers.cs index 8047c5cbd..3b888a57d 100644 --- a/controls/dev/TableView/InteractionTests/TableViewInteractionTestHelpers.cs +++ b/controls/dev/TableView/InteractionTests/TableViewInteractionTestHelpers.cs @@ -721,6 +721,25 @@ namespace Microsoft.UI.Xaml.Tests.MUXControls.InteractionTests return value; } + internal static bool WaitForStatusText(string expected, int timeoutMs = 10000) + { + var status = FindElement.ById(StatusText); + string value = null; + DateTime deadline = DateTime.Now.AddMilliseconds(timeoutMs); + while (status != null) + { + value = status.DocumentText; + if (value == expected || DateTime.Now >= deadline) + { + break; + } + Wait.ForMilliseconds(100); + } + + Log.Comment("Status = '{0}' (expected '{1}').", value, expected); + return value == expected; + } + internal static string ReadEditReport() => ReadPageReadout(EditColumnReport); internal static string ReadEditorProbe() => ReadPageReadout(EditorProbe); diff --git a/controls/dev/TableView/InteractionTests/TableView_Accessibility_InteractionTests.cs b/controls/dev/TableView/InteractionTests/TableView_Accessibility_InteractionTests.cs index e31f24c37..bd80f784b 100644 --- a/controls/dev/TableView/InteractionTests/TableView_Accessibility_InteractionTests.cs +++ b/controls/dev/TableView/InteractionTests/TableView_Accessibility_InteractionTests.cs @@ -163,6 +163,41 @@ namespace Microsoft.UI.Xaml.Tests.MUXControls.InteractionTests } } + [TestMethod] + [TestProperty("Description", "Verifies pinning and unpinning a column raises a Transform.CanResize property-changed event on its header, so a UIA client's cached value stays current.")] + public void ColumnPinChangeRaisesCanResizePropertyChanged() + { + using (var setup = new TestSetupHelper(PageName)) + { + UIObject score = GetHeader(BasicTable, "Score"); + var togglePin = FindElement.ById