diff --git a/.agents/skills/testing/SKILL.md b/.agents/skills/testing/SKILL.md new file mode 100644 index 000000000..9cc04b3e1 --- /dev/null +++ b/.agents/skills/testing/SKILL.md @@ -0,0 +1,61 @@ +--- +name: testing +description: Provides guidance and instructions for writing, running and filtering tests. +user-invocable: false +--- + +**IMPORTANT**: This repository uses xUnit v3 with Microsoft.Testing.Platform (MTP v2). Traditional `--filter` syntax does NOT work. Use the options below instead. + +* There should generally be one test project (under the `test` directory) per shipping project (under the `src` directory). Test projects are named after the project being tested with a `.Tests` suffix. +* Tests use xUnit v3 with Microsoft.Testing.Platform (MTP v2). The threading tests use xUnit's AOT runner on .NET 10 and the reflection-based runner on older targets. +* Run published NativeAOT tests with `tools/dotnet-test-cloud.ps1 -Configuration Release -IncludeNativeAOT` after `dotnet publish test -c Release`. + +## Running Tests + +**Run all tests**: +```bash +dotnet test --no-build -c Release +``` + +**Run tests for a specific test project**: +```bash +dotnet test --no-build -c Release +``` + +**Run a single test method**: +```bash +dotnet test --no-build -c Release -- --filter-method "ClassName.MethodName" +``` + +**Run all tests in a test class**: +```bash +dotnet test --no-build -c Release -- --filter-class "ClassName" +``` + +**Run tests with wildcard matching** (supports wildcards at beginning and/or end): +```bash +dotnet test --no-build -c Release -- --filter-method "*Pattern*" +``` + +**Run tests with a specific trait**: +```bash +dotnet test --no-build -c Release -- --filter-trait "TraitName=value" +``` + +**Run tests for a specific framework only**: +```bash +dotnet test --no-build -c Release --framework net8.0 +``` + +**List all available tests without running them**: +```bash +dotnet run --no-build -c Release --framework net8.0 -- --list-tests +``` + +**Key points about test filtering with xUnit / MTP v2**: +- Options after `--` are passed to the test runner, not to `dotnet test` +- Use `--filter-method`, `--filter-class`, `--filter-namespace`, and `--filter-trait` +- Exclude unstable tests with `--filter-not-trait "TestCategory=FailsInCloudTest"` +- Traditional VSTest `--filter` expressions do NOT work +- Wildcards `*` are supported at the beginning and/or end of filter values +- See `--help` for query filter language for advanced scenarios diff --git a/.agents/skills/update-library-template/SKILL.md b/.agents/skills/update-library-template/SKILL.md index 941df2f2f..99731a42f 100644 --- a/.agents/skills/update-library-template/SKILL.md +++ b/.agents/skills/update-library-template/SKILL.md @@ -30,6 +30,28 @@ Conflicts in the following files should always be resolved by keeping the curren * README.md +### Test framework docs, scripts, and packages + +Library.Template's `AGENTS.md`, test docs, and `tools/dotnet-test-cloud.ps1` assume **TUnit** on Microsoft.Testing.Platform. +Many consumers still use **xunit v3 + MTP**, classic **VSTest xunit**, or hybrid setups (including xunit *extension* libraries such as Xunit.StaFact / Xunit.SkippableFact / Xunit.Combinatorial, whose own tests must stay on xunit). + +When merging: + +* Prefer the template only where the consumer's prior content was **equivalent**. Do not overwrite repo-specific behavior with template placeholders or TUnit-only defaults. +* Keep framework-specific **AGENTS.md** (and similar agent/contributor docs): filter syntax (`--filter-method` / `--filter-not-trait` vs `--treenode-filter`), FailsInCloudTest / FailureExpected exclusions, real test project paths, and `--framework` TFMs that match the repo's test projects. +* Keep bespoke **`tools/dotnet-test-cloud.ps1`** logic when it is not equivalent to the template (per-project loops, NonTUnit vs TUnit splits, MultiRID/NativeAOT discovery, hang/blame timeouts, coverage naming, custom filters). Graft genuine template improvements (for example `IncludeNativeAOT` + `Get-NativeAOTTestProjects.ps1`) onto the repo script only when they fit. +* Do not add unused template **PackageVersion** entries (`TUnit.Engine`, `xunit.v3.assert.aot`, etc.) to `Directory.Packages.props` unless a project in the repo actually references them (or Central Package Management truly requires them). +* Preserve repo-specific Azure Pipelines / GitHub Actions build steps that the template lacks equivalents for (for example full **MSBuild@1** on Windows for `net35`, `IncompleteBuild` as `warnNotAsError`). + +### Traversal coverage, placeholders, and non-test projects + +* `GitVersionBaseDirectory` in the template's `Directory.Build.props` assumes the repo has exactly one `version.json`. If the repo has nested `version.json` files (typically for analyzer or source generator projects that need `assemblyVersion` precision `revision`), the repo has deliberately removed or must not have that property. Never re-add it during a merge; keep the repo's first-parent state. +* `init.ps1` and CI restore and build only `tools/dirs.proj`. If the solution contains projects outside `src` and `test` (for example `samples/` or `benchmark/`), add them to `tools/dirs.proj` (with `Pack="false"` and/or `Publish="false"` as appropriate) so they are still restored and compiled. Otherwise `dotnet format --no-restore` and sample regressions go unnoticed. +* `test/Directory.Build.props` marks every project under `test` as a test project. Set `false` on non-test executables there (for example BenchmarkDotNet projects) so they are not run by `dotnet test` or picked up by NativeAOT test discovery. +* After the merge, search `CONTRIBUTING.md`, `AGENTS.md`, and source headers for template placeholders such as `test/Library.Tests/Library.Tests.csproj`, the company-name placeholder token that `Expand-Template.ps1` replaces with the author, and the template's `net8.0` examples, and replace them with the repo's real project paths, metadata, and NativeAOT-enabled frameworks. +* If the repo already publishes NativeAOT tests through its own mechanism (for example `MultiRIDProjectReference` items in `test/dirs.proj`), either keep that mechanism and disable the template's discovery (`PublishNativeAOTTests=false`, and drop or don't wire up `Get-NativeAOTTestProjects.ps1`), or replace it fully. Don't leave both half-wired, and update `CONTRIBUTING.md` to describe whichever one the repo actually uses. + + ### Deleted files Very typically, when the incoming change is to a file that was deleted locally, the correct resolution is to re-delete the file. diff --git a/.agents/skills/update-library-template/template-release-notes.md b/.agents/skills/update-library-template/template-release-notes.md index 975da315e..62fbddaf4 100644 --- a/.agents/skills/update-library-template/template-release-notes.md +++ b/.agents/skills/update-library-template/template-release-notes.md @@ -18,3 +18,75 @@ dotnet solution EXISTING.sln migrate This will create an EXISTING.slnx file. `git add` that file, then `git rm` the old `.sln` file. Sometimes a repo will reference the sln filename in a script or doc somewhere. Search the repo for such references and update them to the slnx file. + +## Migrating from xUnit to TUnit + +The template now uses TUnit with Microsoft.Testing.Platform instead of the xUnit test runner. +When merging this update into an existing repository, migrate all test projects and repo-specific test automation. +Follow the [TUnit xUnit migration guide](https://tunit.dev/docs/migration/xunit), including its automated migration code fixes. + +* Replace xUnit runner/framework package references with `TUnit.Engine` and set `Exe` in each test project. + Remove obsolete runner dependencies such as `xunit.runner.visualstudio`, `Microsoft.NET.Test.Sdk`, and `coverlet.collector`, along with `xunit.runner.json`. + Reconcile central package versions with the template's `Directory.Packages.props`. +* Existing xUnit assertions can remain: the template retains `xunit.v3.assert`, using `xunit.v3.assert.aot` for .NET 9 and later. + Keep `Xunit` imports for assertions, but use TUnit for test attributes and lifecycle APIs. +* Convert `[Fact]` and `[Theory]` to `[Test]`, and `[InlineData]` to `[Arguments]`. + Migrate member/class data sources, traits, fixtures, collection behavior, setup/teardown, and test output according to the migration guide. + Review tests that depend on xUnit's execution ordering or parallelization rules; do not assume those rules carry over. +* Merge the `global.json` Microsoft.Testing.Platform runner setting, test directory build files, traversal projects, and updated PowerShell/YAML automation. + Keep every test project in the repository's solution for managed test runs. + The traversals discover `.csproj` files under `src` and `test`; adjust discovery if your projects live elsewhere or use another project language. +* Update custom test commands and filters. + VSTest `--filter` expressions do not work with this runner; pass TUnit filters after `--`, for example `-- --treenode-filter "/*/*/ClassName/MethodName"`. + Replace any xUnit-specific CI result or coverage handling with the template's Microsoft.Testing.Platform integration. +* Validate managed tests and coverage, then run `dotnet publish tools\dirs.proj -c Release` and `.\tools\dotnet-test-cloud.ps1 -Configuration Release -IncludeNativeAOT`. + Test projects targeting .NET 8 or later are eligible for NativeAOT publication by default. + If a project cannot support NativeAOT, set `false` in its project file; it will still run as managed tests. + +## Template expansion validation + +The `tools/Validate-TemplateExpansion.ps1` script and the template-expansion validation steps in Azure Pipelines and GitHub Actions exist only to test Library.Template itself. +They clean the working tree, run `Expand-Template.ps1`, and build the expanded result after the normal build and test sequence has completed. + +Do not bring `tools/Validate-TemplateExpansion.ps1`, `azure-pipelines/expand-template.yml`, or the block marked `TEMPLATE EXPANSION VALIDATION` in `.github/workflows/build.yml` into an existing repository. +These files and steps are removed automatically when creating a repository through `Expand-Template.ps1`, and should likewise be discarded while merging template updates into an already-expanded repository. + +## NativeAOT compatibility validation + +Shipping projects now set `IsAotCompatible` for target frameworks compatible with .NET 8. +Apply the same conditional property to each shipping project that is intended to support NativeAOT, then resolve all trim and AOT analyzer warnings rather than suppressing them broadly. + +The new `test/AotCompatibilityTest` project catches NativeAOT compiler warnings that library analyzers may not report. +Keep it in the solution, add every NativeAOT-compatible shipping assembly as a `TrimmerRootAssembly`, and retain its publishable entry in `test/dirs.proj`. +CI validates this project through the existing traversal publish on Windows, Linux, and macOS; it is intentionally marked `IsTestProject=false` because publishing successfully is the test. + +## NativeAOT opt-out and AI merge guidance + +Not every repo wants to take on NativeAOT readiness or NativeAOT-published test validation. +If a receiving repo does not intend to support NativeAOT, opt out explicitly instead of inheriting the template's defaults. + +For test projects that should remain managed-only: + +* Set `false` in the project file or in a repo-level `Directory.Build.props`. +* Leave the managed test project in the solution so usual IL-based testing continues to work. +* Keep `dotnet test` and the managed coverage flow unchanged; only the NativeAOT publish step is disabled. + +For shipping libraries that do not want NativeAOT compatibility analysis: + +* Remove `IsAotCompatible` from the project, or set it only for specific target frameworks that truly support NativeAOT. +* Do not add new AOT compatibility warnings to the build if the library is not intended to be AOT-compatible. +* If the repo does not target .NET 8 or later, the property should be omitted entirely rather than applied unconditionally. + +For the compatibility project itself: + +* Remove the `test/AotCompatibilityTest` project from the solution if NativeAOT validation is out of scope. +* Remove its entry from `test/dirs.proj` so the traversal publish no longer publishes it. +* Keep the project only when the repo intends to validate shipping assemblies under NativeAOT in CI. + +When merging this template into a downstream repo, the AI assistant should ask whether NativeAOT is a relevant target for that repo before applying these changes if `IsAotCompatible` is not already set by any of the projects. +If the answer is no, it should apply the opt-out steps above instead of blindly keeping the NativeAOT defaults. +This is especially important for repos that are not shipping a NativeAOT-ready library or that cannot support the extra publish-time validation in CI. +If the repo owner is unsure about NativeAOT support, you might offer to test the repo for NativeAOT readiness by attempting a NativeAOT publish of the relevant projects and observing whether any compatibility warnings or errors are produced. + +In the event the user elects for NativeAOT targeting, make efforts to get tests to work under NativeAOT as well so the shipping code can confidently advertise its NativeAOT compatibility. +If aspects of the library or tests make successful builds, publish or test runs problematic, discuss this with the repo owner before making significant changes or giving up on NativeAOT support. diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json index ac2120a2d..97c8b80be 100644 --- a/.config/dotnet-tools.json +++ b/.config/dotnet-tools.json @@ -10,7 +10,7 @@ "rollForward": false }, "dotnet-coverage": { - "version": "18.11.2", + "version": "18.12.0", "commands": [ "dotnet-coverage" ], @@ -24,7 +24,7 @@ "rollForward": false }, "docfx": { - "version": "2.78.5", + "version": "2.81.0", "commands": [ "docfx" ], diff --git a/.github/actions/publish-artifacts/action.yaml b/.github/actions/publish-artifacts/action.yaml index 87b2f9c03..880760883 100644 --- a/.github/actions/publish-artifacts/action.yaml +++ b/.github/actions/publish-artifacts/action.yaml @@ -14,46 +14,46 @@ runs: - name: ๐Ÿ“ข Upload project.assets.json files if: always() - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: projectAssetsJson-${{ runner.os }} path: ${{ runner.temp }}/_artifacts/projectAssetsJson continue-on-error: true - name: ๐Ÿ“ข Upload variables - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: variables-${{ runner.os }} path: ${{ runner.temp }}/_artifacts/Variables continue-on-error: true - name: ๐Ÿ“ข Upload build_logs if: always() - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: build_logs-${{ runner.os }} path: ${{ runner.temp }}/_artifacts/build_logs continue-on-error: true - name: ๐Ÿ“ข Upload testResults if: always() - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: testResults-${{ runner.os }} path: ${{ runner.temp }}/_artifacts/testResults continue-on-error: true - name: ๐Ÿ“ข Upload coverageResults if: always() - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: coverageResults-${{ runner.os }} path: ${{ runner.temp }}/_artifacts/coverageResults continue-on-error: true - name: ๐Ÿ“ข Upload symbols - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: symbols-${{ runner.os }} path: ${{ runner.temp }}/_artifacts/symbols continue-on-error: true - name: ๐Ÿ“ข Upload deployables - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: deployables-${{ runner.os }} path: ${{ runner.temp }}/_artifacts/deployables diff --git a/.github/renovate.json b/.github/renovate.json index d5154a48d..0d125c214 100644 --- a/.github/renovate.json +++ b/.github/renovate.json @@ -6,10 +6,37 @@ "github>microsoft/vs-renovate-presets:dotnet_packages_LTS", "github>microsoft/vs-renovate-presets:xunitv2" ], + "labels": ["dependencies"], "packageRules": [ { "matchFileNames": ["Directory.Packages.Analyzers.props"], "enabled": false + }, + { + "matchPackageNames": ["nbgv", "nerdbank.gitversioning"], + "groupName": "nbgv and nerdbank.gitversioning updates" + }, + { + "matchPackageNames": ["xunit*"], + "groupName": "xunit" + }, + { + "matchPackageNames": ["tunit*"], + "groupName": "tunit", + "schedule": ["on the first day of the month"] + }, + { + "matchPackageNames": ["Microsoft.Testing.Extensions.*"], + "groupName": "Microsoft Testing Platform" + }, + { + "matchDatasources": ["dotnet-version", "docker"], + "matchDepNames": ["dotnet-sdk", "mcr.microsoft.com/dotnet/sdk"], + "groupName": "Dockerfile and global.json updates" + }, + { + "matchPackageNames": ["*"], + "allowedVersions": "!/-g[a-f0-9]+$/" } ], "customManagers": [ diff --git a/.github/workflows/copilot-setup-steps.yml b/.github/workflows/copilot-setup-steps.yml index 71adf9e73..74a625667 100644 --- a/.github/workflows/copilot-setup-steps.yml +++ b/.github/workflows/copilot-setup-steps.yml @@ -16,7 +16,7 @@ on: jobs: # The job MUST be called `copilot-setup-steps` or it will not be picked up by Copilot. copilot-setup-steps: - runs-on: ubuntu-latest + runs-on: ubuntu-26.04 # Set the permissions to the lowest permissions possible needed for your steps. # Copilot will be given its own token for its operations. permissions: diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index c0492a90d..9616f6949 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -4,6 +4,7 @@ on: push: branches: - main + workflow_dispatch: # Allow only one concurrent deployment, skipping runs queued between the run in-progress and latest queued. # However, do NOT cancel in-progress runs as we want to allow these production deployments to complete. @@ -22,7 +23,7 @@ jobs: environment: name: github-pages url: ${{ steps.deployment.outputs.page_url }} - runs-on: ubuntu-latest + runs-on: ubuntu-26.04 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: @@ -37,7 +38,12 @@ jobs: uses: actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9 # v5.0.0 with: path: docfx/_site + # Artifacts from prior attempts remain visible on re-runs, so make the name unique per attempt + # to avoid deploy-pages failing due to multiple artifacts with the same name. + name: github-pages-${{ github.run_attempt }} - name: Deploy to GitHub Pages id: deployment uses: actions/deploy-pages@368f82528645a54fb793d4d04e342629a3f51346 # v5.0.1 + with: + artifact_name: github-pages-${{ github.run_attempt }} diff --git a/.github/workflows/docs_validate.yml b/.github/workflows/docs_validate.yml index 80e11913b..15c770da1 100644 --- a/.github/workflows/docs_validate.yml +++ b/.github/workflows/docs_validate.yml @@ -12,18 +12,51 @@ jobs: build: name: ๐Ÿ“š Doc validation runs-on: ubuntu-26.04 + permissions: + contents: read + id-token: write steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - fetch-depth: 0 # avoid shallow clone so nbgv can do its work. - - name: ๐Ÿ”— Markup Link Checker (mlc) - uses: becheran/mlc@7ec24825cefe0c9c8c6bac48430e1f69e3ec356e # v1.2.0 - with: - args: --do-not-warn-for-redirect-to https://learn.microsoft.com*,https://dotnet.microsoft.com/*,https://dev.azure.com/*,https://app.codecov.io/*,https://github.com/microsoft/vssdktestfx*,https://github.com/Microsoft/perfview*,https://github.com/microsoft/perfview* -i https://aka.ms/onboardsupport,https://aka.ms/spot,https://msrc.microsoft.com/*,https://www.microsoft.com/msrc*,https://microsoft.com/msrc*,https://www.npmjs.com/package/*,https://get.dot.net/ - - name: โš™ Install prerequisites - run: | - ./init.ps1 -UpgradePrerequisites - dotnet --info - shell: pwsh - - name: ๐Ÿ“š Verify docfx build - run: dotnet docfx docfx/docfx.json --warningsAsErrors --disableGitFeatures + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 # avoid shallow clone so nbgv can do its work. + - name: ๐Ÿ”— Markup Link Checker (mlc) + uses: becheran/mlc@b0d0d353e04fcbcfd1d86716103d78a61e5b25dd # v1.2.2 + with: + args: --do-not-warn-for-redirect-to https://learn.microsoft.com*,https://dotnet.microsoft.com/*,https://dev.azure.com/*,https://app.codecov.io/*,https://github.com/microsoft/vssdktestfx*,https://github.com/Microsoft/perfview*,https://github.com/microsoft/perfview* -i https://aka.ms/onboardsupport,https://aka.ms/spot,https://msrc.microsoft.com/*,https://www.microsoft.com/msrc*,https://microsoft.com/msrc*,https://www.npmjs.com/package/*,https://get.dot.net/ + - name: Authenticate to Azure with GitHub OIDC + id: azure-login + # This tenant requires enterprise-issued assertions. Other owners restore anonymously. + # Same-repository dependency update PRs authenticate; fork PRs do not. + if: github.repository_owner == 'microsoft' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) + uses: azure/login@a641126d1b8aa4d1fa005f4f92df94a3a4c4c906 # v3.1.0 + with: + client-id: 2799af29-63f3-404f-bdcf-67ff9c70abc9 + tenant-id: 72f988bf-86f1-41af-91ab-2d7cd011db47 + allow-no-subscriptions: true + audience: api://AzureADTokenExchange + - name: Configure Azure Artifacts credentials + if: steps.azure-login.outcome == 'success' + shell: pwsh + run: | + $accessToken = az account get-access-token --resource 499b84ac-1321-427f-aa17-267ca6975798 --query accessToken --output tsv + if ($LASTEXITCODE -ne 0 -or [string]::IsNullOrWhiteSpace($accessToken)) { + throw 'Failed to acquire an Azure DevOps access token.' + } + Write-Output "::add-mask::$accessToken" + $nugetConfig = [xml](Get-Content -LiteralPath nuget.config -Raw) + $sources = @($nugetConfig.configuration.packageSources.add | Where-Object { + $_.value -match '^https://pkgs\.dev\.azure\.com/' -or $_.value -match '^https://[\w-]+\.pkgs\.visualstudio\.com/' + }) + if ($sources.Count -eq 0) { + throw 'No Azure Artifacts package sources were found in nuget.config.' + } + foreach ($source in $sources) { + Add-Content -LiteralPath $env:GITHUB_ENV -Encoding utf8 -Value "NuGetPackageSourceCredentials_$($source.key)=Username=ado;Password=$accessToken;ValidAuthenticationTypes=Basic" + } + - name: โš™ Install prerequisites + run: | + ./init.ps1 -UpgradePrerequisites + dotnet --info + shell: pwsh + - name: ๐Ÿ“š Verify docfx build + run: dotnet docfx docfx/docfx.json --warningsAsErrors --disableGitFeatures diff --git a/.github/workflows/libtemplate-update.yml b/.github/workflows/libtemplate-update.yml index af1a9eef8..3de947a12 100644 --- a/.github/workflows/libtemplate-update.yml +++ b/.github/workflows/libtemplate-update.yml @@ -12,7 +12,7 @@ on: jobs: merge: - runs-on: ubuntu-latest + runs-on: ubuntu-26.04 permissions: contents: write pull-requests: write diff --git a/AGENTS.md b/AGENTS.md index 387894689..abc62c5c3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -84,4 +84,4 @@ dotnet run --no-build -c Release --framework net9.0 -- --list-tests * Honor StyleCop rules and fix any reported build warnings *after* getting tests to pass. * In C# files, use namespace *statements* instead of namespace *blocks* for all new files that define namespaces. * Test files are *not* expected to declare namespaces. -* Add API doc comments to all new public and internal members. +* Add API doc comments to all new public and internal members in shipping code under `src`. Tests and samples do not require XML API documentation; do not add XML docs to test or sample members solely to satisfy this rule. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 2af8f46f5..becf7ad86 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -45,6 +45,27 @@ You can use `dotnet test` to build and/or test the repo. There may be tests that are known to be unstable or have special requirements. These can be avoided by running tests using the [dotnet-test-cloud.ps1](tools/dotnet-test-cloud.ps1) script *after* running `dotnet build`. +To build both managed and NativeAOT tests, run `dotnet publish test -c Release`. +Then run `./tools/dotnet-test-cloud.ps1 -Configuration Release -IncludeNativeAOT`. +The threading library tests retain xUnit. Their .NET 10 target uses `xunit.v3.aot.mtp-v2` +and is the only unit-test target published as NativeAOT; older frameworks use the +reflection-based xUnit runner. Analyzer tests remain managed-only. +The shared tests use built-in xUnit theory data and explicit STA/UI-thread helpers +because the reflection-based combinatorial and STA discoverers cannot run under NativeAOT. +Keep test projects in the solution as well: managed test runs still use the solution, while NativeAOT runs use the traversal's evaluated executable paths. +Test projects can opt out of NativeAOT publishing with `false`. +One restore includes all test target frameworks, runtime identifiers, and NativeAOT compiler dependencies. +Managed builds are RID-neutral by default; NativeAOT builds use the SDK's runtime-specific output directories. +For a specified RID, managed execution and native publishing can share the same build outputs. +Test builds keep dynamic code, startup hooks, and event tracing enabled for managed code coverage. +The `ConfigureNativeAOTTestFeatures` target disables those features only in the native compiler's publish-time inputs, without rewriting the managed runtime configuration. +It preserves all other runtime feature options, including invariant globalization, so native compilation and linking use consistent settings. +Test builds use invariant globalization and retain only English satellite resources; RID-specific builds are self-contained. +Shipping libraries targeting .NET opt into NativeAOT compatibility analysis with `IsAotCompatible`. +The `test/NativeAOTCompatibility.Test` project complements those analyzers by rooting the shipping assembly and passing it through the NativeAOT compiler during every traversal publish. +Add each shipping assembly that must be validated as a `TrimmerRootAssembly`, and keep this project publishable in `test/dirs.proj`. +Root `Directory.Build.props` supplies project-reference defaults for both traversal and SDK projects that remove the `_IsPublishing` global property for managed dependencies, avoiding duplicate project instances that write to the same outputs during parallel publishing. + ## Releases Use `nbgv tag` to create a tag for a particular commit that you mean to release. @@ -66,6 +87,46 @@ After making a change, you can rebuild the docs site while the localhost server The `.github/workflows/docs.yml` GitHub Actions workflow publishes the content of these docs to github.io if the workflow itself and [GitHub Pages is enabled for your repository](https://docs.github.com/en/pages/quickstart). +### Documentation validation feed authentication + +The `.github/workflows/docs_validate.yml` workflow uses GitHub OIDC to authenticate +as the **azure-public/vside package pull** Entra application before `init.ps1` restores packages. +It requests an Azure DevOps access token and supplies it through +`NuGetPackageSourceCredentials_` for each Azure Artifacts source in +`nuget.config`, including repositories that use a different name such as +`msft_consumption_public`. +No client secret or PAT is required, and no credentials are written to `nuget.config`. + +Run `tools/Configure-GitHubOidc.ps1` from the repository to configure GitHub's immutable +OIDC subject format and create matching Entra federated credentials. +Sign in with `gh auth login` and +`az login --tenant 72f988bf-86f1-41af-91ab-2d7cd011db47 --allow-no-subscriptions` first. +The signed-in identities need repository administration and permission to manage the app's +federated credentials. Use `-WhatIf` to preview without making changes. +The script discovers the calling repository, trusts pull requests plus its default branch, +and can be rerun without duplicating credentials. +Use `-Branches microbuild,release` to add other trusted branches; the default branch +and PR context are always included. The script reports each created or reused credential. +Credential names identify the owner, repository, and PR or branch context, for example +`github-AArnott-Library.Template-pull-request` and +`github-AArnott-Library.Template-branch-main`. Names requiring punctuation replacement +or truncation include a short hash suffix to preserve uniqueness within Entra's name limits. + +This changes OIDC subjects for **all workflows** in the repository. Update any other cloud +trust policies (including environment subjects) before running it; existing Entra credentials +are preserved. The script does not configure Azure DevOps feed permissions. + +Authentication is enabled only for repositories owned by the `microsoft` organization, +because this Entra tenant requires enterprise-issued GitHub assertions. +Same-repository dependency update PRs, including Renovate and Dependabot, authenticate +using the job's explicit `id-token: write` permission. +Repositories owned by non-microsoft accounts and fork PRs +skip authentication and retain anonymous restore behavior; +new upstream dependencies may still need to be ingested by a trusted run first. +Do not switch this workflow to `pull_request_target` to give untrusted PR code credentials. +Repositories based on this template must configure their own trusted subjects and, if necessary, +update the application and tenant IDs in the workflow. + ## Updating dependencies This repo uses Renovate to keep dependencies current. diff --git a/Directory.Build.props b/Directory.Build.props index 62f74ed37..24cb72490 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -54,7 +54,6 @@ arm64 $(RidOsPrefix)-$(RidOsArchitecture) - $(DefaultRuntimeIdentifier) @@ -67,6 +66,11 @@ + + + _IsPublishing + _IsPublishing + diff --git a/Directory.Packages.props b/Directory.Packages.props index 67f015e79..873daa0f2 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -4,7 +4,8 @@ true true - 2.4.1 + 2.5.1 + 2.0.257 5.9.0 1.1.4 @@ -37,17 +38,18 @@ - - + + + diff --git a/azure-pipelines/archive-sourcecode.yml b/azure-pipelines/archive-sourcecode.yml index 13015bef5..28de232e9 100644 --- a/azure-pipelines/archive-sourcecode.yml +++ b/azure-pipelines/archive-sourcecode.yml @@ -36,7 +36,7 @@ extends: template: azure-pipelines/MicroBuild.1ES.Official.yml@MicroBuildTemplate parameters: settings: - networkIsolationPolicy: DefaultDeny,CFSClean3 + networkIsolationPolicy: DefaultDeny,CFSClean,CFSClean2,CFSClean3 sdl: sourceAnalysisPool: VSEng-MicroBuildVSStable diff --git a/azure-pipelines/build.yml b/azure-pipelines/build.yml index 646659295..e09367911 100644 --- a/azure-pipelines/build.yml +++ b/azure-pipelines/build.yml @@ -102,7 +102,7 @@ parameters: - name: linuxPool type: object default: - vmImage: ubuntu-24.04 + vmImage: ubuntu-26.04 - name: macOSPool type: object default: diff --git a/azure-pipelines/dotnet.yml b/azure-pipelines/dotnet.yml index fb6a8c3d3..98d07834c 100644 --- a/azure-pipelines/dotnet.yml +++ b/azure-pipelines/dotnet.yml @@ -10,13 +10,21 @@ parameters: steps: -- script: dotnet build tools/dirs.proj -t:build,pack,publish --no-restore -c $(BuildConfiguration) -warnAsError -warnNotAsError:NU1901,NU1902,NU1903,NU1904,LOCTASK002 /bl:"$(Build.ArtifactStagingDirectory)/build_logs/build.binlog" - displayName: ๐Ÿ›  dotnet build - ${{ if parameters.BuildRequiresAccessToken }}: - env: - SYSTEM_ACCESSTOKEN: $(System.AccessToken) +- ${{ if parameters.RunTests }}: + - script: dotnet build tools/dirs.proj -t:build,pack,publish --no-restore -c $(BuildConfiguration) -warnAsError -warnNotAsError:NU1901,NU1902,NU1903,NU1904,LOCTASK002 /bl:"$(Build.ArtifactStagingDirectory)/build_logs/build.binlog" + displayName: ๐Ÿ›  dotnet build + ${{ if parameters.BuildRequiresAccessToken }}: + env: + SYSTEM_ACCESSTOKEN: $(System.AccessToken) + +- ${{ if not(parameters.RunTests) }}: + - script: dotnet build tools/dirs.proj -t:build,pack --no-restore -c $(BuildConfiguration) -warnAsError -warnNotAsError:NU1901,NU1902,NU1903,NU1904,LOCTASK002 /bl:"$(Build.ArtifactStagingDirectory)/build_logs/build.binlog" + displayName: ๐Ÿ›  dotnet build + ${{ if parameters.BuildRequiresAccessToken }}: + env: + SYSTEM_ACCESSTOKEN: $(System.AccessToken) -- powershell: tools/dotnet-test-cloud.ps1 -Configuration $(BuildConfiguration) -Agent $(Agent.JobName) -PublishResults +- powershell: tools/dotnet-test-cloud.ps1 -Configuration $(BuildConfiguration) -Agent $(Agent.JobName) -PublishResults -IncludeNativeAOT displayName: ๐Ÿงช dotnet test condition: and(succeeded(), ${{ parameters.RunTests }}) diff --git a/azure-pipelines/libtemplate-update.yml b/azure-pipelines/libtemplate-update.yml index 8b342de2b..aaeb690f6 100644 --- a/azure-pipelines/libtemplate-update.yml +++ b/azure-pipelines/libtemplate-update.yml @@ -31,7 +31,7 @@ extends: template: azure-pipelines/MicroBuild.1ES.Unofficial.yml@MicroBuildTemplate parameters: settings: - networkIsolationPolicy: DefaultDeny,CFSClean3 + networkIsolationPolicy: DefaultDeny,CFSClean,CFSClean2,CFSClean3 sdl: sourceAnalysisPool: name: AzurePipelines-EO diff --git a/azure-pipelines/official.yml b/azure-pipelines/official.yml index edfc2e6f5..d0c99075d 100644 --- a/azure-pipelines/official.yml +++ b/azure-pipelines/official.yml @@ -46,7 +46,7 @@ extends: template: azure-pipelines/MicroBuild.1ES.Official.yml@MicroBuildTemplate parameters: settings: - networkIsolationPolicy: DefaultDeny,CFSClean3 + networkIsolationPolicy: DefaultDeny,CFSClean,CFSClean2,CFSClean3 sdl: sourceAnalysisPool: VSEng-MicroBuildVSStable codeSignValidation: diff --git a/azure-pipelines/release.yml b/azure-pipelines/release.yml index d435dab3b..886ab8643 100644 --- a/azure-pipelines/release.yml +++ b/azure-pipelines/release.yml @@ -21,7 +21,7 @@ extends: template: azure-pipelines/MicroBuild.1ES.Official.yml@MicroBuildTemplate parameters: settings: - networkIsolationPolicy: DefaultDeny,CFSClean3 + networkIsolationPolicy: DefaultDeny,CFSClean,CFSClean2,CFSClean3 sdl: sourceAnalysisPool: VSEng-MicroBuildVSStable diff --git a/azure-pipelines/unofficial.yml b/azure-pipelines/unofficial.yml index ef52618c8..ca9b7693a 100644 --- a/azure-pipelines/unofficial.yml +++ b/azure-pipelines/unofficial.yml @@ -57,7 +57,7 @@ extends: template: azure-pipelines/MicroBuild.1ES.Unofficial.yml@MicroBuildTemplate parameters: settings: - networkIsolationPolicy: DefaultDeny,CFSClean3 + networkIsolationPolicy: DefaultDeny,CFSClean,CFSClean2,CFSClean3 sdl: sourceAnalysisPool: VSEng-MicroBuildVSStable credscan: diff --git a/azure-pipelines/vs-insertion.yml b/azure-pipelines/vs-insertion.yml index 1851e4ea6..71499572b 100644 --- a/azure-pipelines/vs-insertion.yml +++ b/azure-pipelines/vs-insertion.yml @@ -23,7 +23,7 @@ extends: template: azure-pipelines/MicroBuild.1ES.Official.yml@MicroBuildTemplate parameters: settings: - networkIsolationPolicy: DefaultDeny,CFSClean3 + networkIsolationPolicy: DefaultDeny,CFSClean,CFSClean2,CFSClean3 sdl: sourceAnalysisPool: VSEng-MicroBuildVSStable sbom: diff --git a/azure-pipelines/vs-validation.yml b/azure-pipelines/vs-validation.yml index 195181a8b..49786dedd 100644 --- a/azure-pipelines/vs-validation.yml +++ b/azure-pipelines/vs-validation.yml @@ -27,7 +27,7 @@ extends: template: azure-pipelines/MicroBuild.1ES.Unofficial.yml@MicroBuildTemplate parameters: settings: - networkIsolationPolicy: DefaultDeny,CFSClean3 + networkIsolationPolicy: DefaultDeny,CFSClean,CFSClean2,CFSClean3 sdl: sourceAnalysisPool: VSEng-MicroBuildVSStable credscan: diff --git a/global.json b/global.json index 00a82c8e6..a5010ea45 100644 --- a/global.json +++ b/global.json @@ -9,6 +9,6 @@ }, "msbuild-sdks": { "Microsoft.Build.NoTargets": "3.7.134", - "Microsoft.Build.Traversal": "4.1.82" + "Microsoft.Build.Traversal": "4.1.82" } } diff --git a/init.ps1 b/init.ps1 index 183a7156d..22f7c525c 100755 --- a/init.ps1 +++ b/init.ps1 @@ -82,7 +82,7 @@ if (!$NoPrerequisites) { & "$PSScriptRoot\tools\Install-NuGetCredProvider.ps1" -AccessToken $AccessToken -Force:$UpgradePrerequisites } - & "$PSScriptRoot\tools\Install-DotNetSdk.ps1" -InstallLocality $InstallLocality + & "$PSScriptRoot\tools\Install-DotNetSdk.ps1" -InstallLocality $InstallLocality -Upgrade:$UpgradePrerequisites if ($LASTEXITCODE -eq 3010) { Exit 3010 } @@ -109,10 +109,11 @@ try { if (!$NoRestore -and $PSCmdlet.ShouldProcess("NuGet packages", "Restore")) { Write-Host "Restoring NuGet packages" -ForegroundColor $HeaderColor - dotnet restore @RestoreArguments + dotnet restore "$PSScriptRoot/tools/dirs.proj" @RestoreArguments if ($lastexitcode -ne 0) { throw "Failure while restoring packages." } + } if (!$NoToolRestore -and $PSCmdlet.ShouldProcess("dotnet tool", "restore")) { diff --git a/test/AOT.props b/test/AOT.props index 27d5cbf21..d7b62a679 100644 --- a/test/AOT.props +++ b/test/AOT.props @@ -1,16 +1,43 @@ Exe - true true - true $(AvailableRuntimeIdentifiers) - $(DefaultRuntimeIdentifier) + $(DefaultRuntimeIdentifier) + + + + true + true + true + + + + true + en + + + true - - RuntimeIdentifier + %(ProjectReference.GlobalPropertiesToRemove);RuntimeIdentifier;NativeAOT + + + + false + false + false + + + + + false + + + diff --git a/test/Directory.Build.props b/test/Directory.Build.props index 466be54e4..ddfaa00f2 100644 --- a/test/Directory.Build.props +++ b/test/Directory.Build.props @@ -3,11 +3,17 @@ + true + true false true true + + $(DefaultRuntimeIdentifier) + + diff --git a/test/Directory.Build.targets b/test/Directory.Build.targets index 3758bb8c9..1e46d9eb4 100644 --- a/test/Directory.Build.targets +++ b/test/Directory.Build.targets @@ -1,14 +1,54 @@ + + + false + + + + + + $(NoWarn);IL2104;IL3053 + + - - - - + + + + <_TestFramework Include="$(TargetFrameworks)" /> + + + + + + + + + + + + <_NativeExecutableExtension Condition="$([MSBuild]::IsOsPlatform('Windows'))">.exe + + + <_NativeAOTTestExecutable Include="$(MSBuildProjectFullPath)"> + $(MSBuildProjectName) + $(TargetFramework) + $([MSBuild]::NormalizePath('$(MSBuildProjectDirectory)', '$(PublishDir)', '$(AssemblyName)$(_NativeExecutableExtension)')) + + + diff --git a/test/Microsoft.VisualStudio.Threading.Analyzers.Tests/Microsoft.VisualStudio.Threading.Analyzers.Tests.csproj b/test/Microsoft.VisualStudio.Threading.Analyzers.Tests/Microsoft.VisualStudio.Threading.Analyzers.Tests.csproj index a0a354341..3e911ecef 100644 --- a/test/Microsoft.VisualStudio.Threading.Analyzers.Tests/Microsoft.VisualStudio.Threading.Analyzers.Tests.csproj +++ b/test/Microsoft.VisualStudio.Threading.Analyzers.Tests/Microsoft.VisualStudio.Threading.Analyzers.Tests.csproj @@ -1,7 +1,9 @@ -๏ปฟ + + net8.0 Exe + false true true $(NoWarn);NU1701 @@ -66,4 +68,9 @@ + + + + true + diff --git a/test/Microsoft.VisualStudio.Threading.Tests/AsyncLazyInitializerTests.cs b/test/Microsoft.VisualStudio.Threading.Tests/AsyncLazyInitializerTests.cs index 69eb2dccd..8ebcd34dc 100644 --- a/test/Microsoft.VisualStudio.Threading.Tests/AsyncLazyInitializerTests.cs +++ b/test/Microsoft.VisualStudio.Threading.Tests/AsyncLazyInitializerTests.cs @@ -12,7 +12,9 @@ public AsyncLazyInitializerTests(ITestOutputHelper logger) { } - [Theory, CombinatorialData] + [Theory] + [InlineData(false)] + [InlineData(true)] public void Ctor_NullAction(bool specifyJtf) { JoinableTaskFactory? jtf = specifyJtf ? new JoinableTaskContext().Factory : null; // use our own so we don't get main thread deadlocks, which isn't the point of this test. diff --git a/test/Microsoft.VisualStudio.Threading.Tests/AsyncLazyTests.cs b/test/Microsoft.VisualStudio.Threading.Tests/AsyncLazyTests.cs index 21254abed..32cec054f 100644 --- a/test/Microsoft.VisualStudio.Threading.Tests/AsyncLazyTests.cs +++ b/test/Microsoft.VisualStudio.Threading.Tests/AsyncLazyTests.cs @@ -86,7 +86,9 @@ public void CtorNullArgs() /// Verifies that multiple sequential calls to /// do not result in multiple invocations of the value factory. /// - [Theory, CombinatorialData] + [Theory] + [InlineData(false)] + [InlineData(true)] public async Task ValueFactoryExecutedOnlyOnceSequential(bool specifyJtf) { JoinableTaskFactory? jtf = specifyJtf ? new JoinableTaskContext().Factory : null; // use our own so we don't get main thread deadlocks, which isn't the point of this test. @@ -113,7 +115,9 @@ async delegate /// Verifies that multiple concurrent calls to /// do not result in multiple invocations of the value factory. /// - [Theory, CombinatorialData] + [Theory] + [InlineData(false)] + [InlineData(true)] public void ValueFactoryExecutedOnlyOnceConcurrent(bool specifyJtf) { JoinableTaskFactory? jtf = specifyJtf ? new JoinableTaskContext().Factory : null; // use our own so we don't get main thread deadlocks, which isn't the point of this test. @@ -163,7 +167,9 @@ public void ValueFactoryReleasedAfterExecution() GC.KeepAlive(lazy); } - [Theory, CombinatorialData] + [Theory] + [InlineData(false)] + [InlineData(true)] public async Task AsyncPumpReleasedAfterExecution(bool throwInValueFactory) { WeakReference collectible = await this.AsyncPumpReleasedAfterExecution_Helper(throwInValueFactory); @@ -178,7 +184,9 @@ public async Task AsyncPumpReleasedAfterExecution(bool throwInValueFactory) Assert.False(collectible.IsAlive); } - [Theory, CombinatorialData] + [Theory] + [InlineData(false)] + [InlineData(true)] public void ValueFactoryThrowsSynchronously(bool specifyJtf) { JoinableTaskFactory? jtf = specifyJtf ? new JoinableTaskContext().Factory : null; // use our own so we don't get main thread deadlocks, which isn't the point of this test. @@ -199,7 +207,9 @@ public void ValueFactoryThrowsSynchronously(bool specifyJtf) Assert.IsType(task1.Exception!.InnerException); } - [Theory, CombinatorialData] + [Theory] + [InlineData(false)] + [InlineData(true)] public async Task ValueFactoryReentersValueFactorySynchronously(bool specifyJtf) { JoinableTaskFactory? jtf = specifyJtf ? new JoinableTaskContext().Factory : null; // use our own so we don't get main thread deadlocks, which isn't the point of this test. @@ -221,7 +231,9 @@ public async Task ValueFactoryReentersValueFactorySynchronously(bool specifyJtf) await Assert.ThrowsAsync(() => lazy.GetValueAsync()); } - [Theory, CombinatorialData] + [Theory] + [InlineData(false)] + [InlineData(true)] public async Task ValueFactoryReentersValueFactoryAsynchronously(bool specifyJtf) { JoinableTaskFactory? jtf = specifyJtf ? new JoinableTaskContext().Factory : null; // use our own so we don't get main thread deadlocks, which isn't the point of this test. @@ -293,7 +305,9 @@ public async Task GetValueAsyncAlreadyCompletedWithCancellationTokenPreCanceled( Assert.True(lazy.IsValueFactoryCompleted); } - [Theory, CombinatorialData] + [Theory] + [InlineData(false)] + [InlineData(true)] public void GetValue(bool specifyJtf) { JoinableTaskFactory? jtf = specifyJtf ? new JoinableTaskContext().Factory : null; // use our own so we don't get main thread deadlocks, which isn't the point of this test. @@ -301,7 +315,9 @@ public void GetValue(bool specifyJtf) Assert.Equal(5, lazy.GetValue().Data); } - [Theory, CombinatorialData] + [Theory] + [InlineData(false)] + [InlineData(true)] public void GetValue_Precanceled(bool specifyJtf) { JoinableTaskFactory? jtf = specifyJtf ? new JoinableTaskContext().Factory : null; // use our own so we don't get main thread deadlocks, which isn't the point of this test. @@ -309,7 +325,9 @@ public void GetValue_Precanceled(bool specifyJtf) Assert.Throws(() => lazy.GetValue(new CancellationToken(canceled: true))); } - [Theory, CombinatorialData] + [Theory] + [InlineData(false)] + [InlineData(true)] public async Task GetValue_CalledAfterGetValueAsyncHasCompleted(bool specifyJtf) { JoinableTaskFactory? jtf = specifyJtf ? new JoinableTaskContext().Factory : null; // use our own so we don't get main thread deadlocks, which isn't the point of this test. @@ -318,7 +336,9 @@ public async Task GetValue_CalledAfterGetValueAsyncHasCompleted(bool specifyJtf) Assert.Same(result, lazy.GetValue()); } - [Theory, CombinatorialData] + [Theory] + [InlineData(false)] + [InlineData(true)] public async Task GetValue_CalledAfterGetValueAsync_InProgress(bool specifyJtf) { var completeValueFactory = new AsyncManualResetEvent(); @@ -339,7 +359,9 @@ async delegate Assert.Same(results[0], results[1]); } - [Theory, CombinatorialData] + [Theory] + [InlineData(false)] + [InlineData(true)] public async Task GetValue_ThenCanceled(bool specifyJtf) { var completeValueFactory = new AsyncManualResetEvent(); @@ -358,7 +380,9 @@ async delegate await Assert.ThrowsAnyAsync(() => getValueTask); } - [Theory, CombinatorialData] + [Theory] + [InlineData(false)] + [InlineData(true)] public void GetValue_ValueFactoryThrows(bool specifyJtf) { var exception = new InvalidOperationException(); @@ -407,7 +431,15 @@ public void ToStringForFaultedValue() } [Theory] - [CombinatorialData] + [InlineData(NamedSyncContext.None, NamedSyncContext.None)] + [InlineData(NamedSyncContext.None, NamedSyncContext.A)] + [InlineData(NamedSyncContext.None, NamedSyncContext.B)] + [InlineData(NamedSyncContext.A, NamedSyncContext.None)] + [InlineData(NamedSyncContext.A, NamedSyncContext.A)] + [InlineData(NamedSyncContext.A, NamedSyncContext.B)] + [InlineData(NamedSyncContext.B, NamedSyncContext.None)] + [InlineData(NamedSyncContext.B, NamedSyncContext.A)] + [InlineData(NamedSyncContext.B, NamedSyncContext.B)] public void AsyncLazy_CompletesOnThreadWithValueFactory(NamedSyncContext invokeOn, NamedSyncContext completeOn) { // Set up various SynchronizationContexts that we may invoke or complete the async method with. @@ -495,7 +527,8 @@ async delegate /// Verifies that no deadlock occurs if the value factory synchronously blocks while switching to the UI thread. /// [Theory] - [CombinatorialData] + [InlineData(false)] + [InlineData(true)] public void ValueFactoryRequiresMainThreadHeldByOtherSync(bool passJtfToLazyCtor) { SynchronizationContext? ctxt = SingleThreadedTestSynchronizationContext.New(); @@ -784,7 +817,9 @@ async Task FireAndForgetCodeAsync() } } - [Theory, PairwiseData] + [Theory] + [InlineData(false)] + [InlineData(true)] public async Task SuppressRecursiveFactoryDetection_WithJTF(bool suppressWithJTF) { JoinableTaskContext? context = this.InitializeJTCAndSC(); @@ -856,7 +891,10 @@ public async Task Dispose_ValueType_Completed() await this.AssertDisposedLazyAsync(lazy); } - [Theory, CombinatorialData] + [Theory] + [InlineData(DisposeStyle.IDisposable)] + [InlineData(DisposeStyle.SystemIAsyncDisposable)] + [InlineData(DisposeStyle.ThreadingIAsyncDisposable)] public async Task Dispose_Disposable_Completed(DisposeStyle variety) { AsyncLazy lazy = new(() => Task.FromResult(DisposableFactory(variety))); @@ -875,7 +913,10 @@ public async Task Dispose_NonDisposable_Completed() await this.AssertDisposedLazyAsync(lazy); } - [Theory, CombinatorialData] + [Theory] + [InlineData(DisposeStyle.IDisposable)] + [InlineData(DisposeStyle.SystemIAsyncDisposable)] + [InlineData(DisposeStyle.ThreadingIAsyncDisposable)] public async Task Dispose_Disposable_Incomplete(DisposeStyle variety) { AsyncManualResetEvent unblock = new(); diff --git a/test/Microsoft.VisualStudio.Threading.Tests/AsyncReaderWriterLockTests.cs b/test/Microsoft.VisualStudio.Threading.Tests/AsyncReaderWriterLockTests.cs index e3daf893c..5f2654c70 100644 --- a/test/Microsoft.VisualStudio.Threading.Tests/AsyncReaderWriterLockTests.cs +++ b/test/Microsoft.VisualStudio.Threading.Tests/AsyncReaderWriterLockTests.cs @@ -3,6 +3,7 @@ using System; using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; using System.Linq; using System.Reflection; using System.Runtime.InteropServices; @@ -834,10 +835,13 @@ await Task.WhenAll( writeLockHeld.Task); } - [StaFact] + [Fact] public void ReadLockReleaseOnSta() { - this.LockReleaseTestHelper(this.asyncLock.ReadLockAsync()); + TestUtilities.RunOnSta(() => + { + this.LockReleaseTestHelper(this.asyncLock.ReadLockAsync()); + }); } #if ISOLATED_TEST_SUPPORT @@ -861,30 +865,36 @@ public async Task NestedReadLockAsyncGarbageCheck() } #endif - [StaFact] + [Fact] public void LockAsyncThrowsOnGetResultBySta() { - Assert.Equal(ApartmentState.STA, Thread.CurrentThread.GetApartmentState()); // STA required. + TestUtilities.RunOnSta(() => + { + Assert.Equal(ApartmentState.STA, Thread.CurrentThread.GetApartmentState()); // STA required. - AsyncReaderWriterLock.Awaiter? awaiter = this.asyncLock.ReadLockAsync().GetAwaiter(); - Assert.Throws(() => awaiter.GetResult()); // throws on an STA thread + AsyncReaderWriterLock.Awaiter? awaiter = this.asyncLock.ReadLockAsync().GetAwaiter(); + Assert.Throws(() => awaiter.GetResult()); // throws on an STA thread + }); } - [StaFact] + [Fact] public void LockAsyncNotIssuedTillGetResultOnSta() { - AsyncReaderWriterLock.Awaiter? awaiter = this.asyncLock.ReadLockAsync().GetAwaiter(); - Assert.False(this.asyncLock.IsReadLockHeld); - try + TestUtilities.RunOnSta(() => { - awaiter.GetResult(); - } - catch (InvalidOperationException) - { - // This exception happens because we invoke it on the STA. - // But we have to invoke it so that the lock is effectively canceled - // so the test doesn't hang in Cleanup. - } + AsyncReaderWriterLock.Awaiter? awaiter = this.asyncLock.ReadLockAsync().GetAwaiter(); + Assert.False(this.asyncLock.IsReadLockHeld); + try + { + awaiter.GetResult(); + } + catch (InvalidOperationException) + { + // This exception happens because we invoke it on the STA. + // But we have to invoke it so that the lock is effectively canceled + // so the test doesn't hang in Cleanup. + } + }); } [Fact] @@ -1121,10 +1131,13 @@ public async Task UpgradeableReadLockAsyncWithStickyWrite() Assert.False(this.asyncLock.IsWriteLockHeld); } - [StaFact] + [Fact] public void UpgradeableReadLockAsyncReleaseOnSta() { - this.LockReleaseTestHelper(this.asyncLock.UpgradeableReadLockAsync()); + TestUtilities.RunOnSta(() => + { + this.LockReleaseTestHelper(this.asyncLock.UpgradeableReadLockAsync()); + }); } #if ISOLATED_TEST_SUPPORT @@ -1488,10 +1501,13 @@ public async Task WriteLockAsync() Assert.False(this.asyncLock.IsWriteLockHeld); } - [StaFact] + [Fact] public void WriteLockAsyncReleaseOnSta() { - this.LockReleaseTestHelper(this.asyncLock.WriteLockAsync()); + TestUtilities.RunOnSta(() => + { + this.LockReleaseTestHelper(this.asyncLock.WriteLockAsync()); + }); } [Fact] @@ -2658,21 +2674,24 @@ await Task.Run(delegate }); } - [StaFact] + [Fact] public void PrecancelledWriteLockAsyncRequestOnSTA() { - var cts = new CancellationTokenSource(); - cts.Cancel(); - AsyncReaderWriterLock.Awaiter? awaiter = this.asyncLock.WriteLockAsync(cts.Token).GetAwaiter(); - Assert.True(awaiter.IsCompleted); - try + TestUtilities.RunOnSta(() => { - awaiter.GetResult(); - Assert.Fail("Expected OperationCanceledException not thrown."); - } - catch (OperationCanceledException) - { - } + var cts = new CancellationTokenSource(); + cts.Cancel(); + AsyncReaderWriterLock.Awaiter? awaiter = this.asyncLock.WriteLockAsync(cts.Token).GetAwaiter(); + Assert.True(awaiter.IsCompleted); + try + { + awaiter.GetResult(); + Assert.Fail("Expected OperationCanceledException not thrown."); + } + catch (OperationCanceledException) + { + } + }); } [Fact] @@ -2785,46 +2804,49 @@ public async Task CancelNonImpactfulToIssuedLocks() Assert.False(this.asyncLock.IsWriteLockHeld); } - [StaFact] + [Fact] public async Task CancelJustBeforeIsCompletedNoLeak() { - var lockAwaitFinished = new TaskCompletionSource(); - var cts = new CancellationTokenSource(); + await TestUtilities.RunOnSta(async () => + { + var lockAwaitFinished = new TaskCompletionSource(); + var cts = new CancellationTokenSource(); - AsyncReaderWriterLock.Awaitable awaitable = this.asyncLock.UpgradeableReadLockAsync(cts.Token); - AsyncReaderWriterLock.Awaiter? awaiter = awaitable.GetAwaiter(); - cts.Cancel(); + AsyncReaderWriterLock.Awaitable awaitable = this.asyncLock.UpgradeableReadLockAsync(cts.Token); + AsyncReaderWriterLock.Awaiter? awaiter = awaitable.GetAwaiter(); + cts.Cancel(); - if (awaiter.IsCompleted) - { - // The lock should not be issued on an STA thread - Assert.ThrowsAny(() => awaiter.GetResult().Dispose()); - await lockAwaitFinished.SetAsync(); - } - else - { - awaiter.OnCompleted(delegate + if (awaiter.IsCompleted) { - try - { - awaiter.GetResult().Dispose(); - Assert.Equal(ApartmentState.MTA, Thread.CurrentThread.GetApartmentState()); - } - catch (OperationCanceledException) + // The lock should not be issued on an STA thread + Assert.ThrowsAny(() => awaiter.GetResult().Dispose()); + await lockAwaitFinished.SetAsync(); + } + else + { + awaiter.OnCompleted(delegate { - } + try + { + awaiter.GetResult().Dispose(); + Assert.Equal(ApartmentState.MTA, Thread.CurrentThread.GetApartmentState()); + } + catch (OperationCanceledException) + { + } - lockAwaitFinished.SetAsync(); - }); - } + lockAwaitFinished.SetAsync(); + }); + } - await lockAwaitFinished.Task.WithTimeout(UnexpectedTimeout); + await lockAwaitFinished.Task.WithTimeout(UnexpectedTimeout); - // No lock is leaked - using (await this.asyncLock.UpgradeableReadLockAsync()) - { - Assert.Equal(ApartmentState.MTA, Thread.CurrentThread.GetApartmentState()); - } + // No lock is leaked + using (await this.asyncLock.UpgradeableReadLockAsync()) + { + Assert.Equal(ApartmentState.MTA, Thread.CurrentThread.GetApartmentState()); + } + }); } [Fact] @@ -3010,25 +3032,28 @@ public async Task CancelWriteLockDoesNotUnblocksReadLocksIncorrectly() await readLockAcquired.WaitAsync(); } - [StaFact] + [Fact] public void CompleteBlocksNewTopLevelLocksSTA() { - Assert.Equal(ApartmentState.STA, Thread.CurrentThread.GetApartmentState()); // test requires STA + TestUtilities.RunOnSta(() => + { + Assert.Equal(ApartmentState.STA, Thread.CurrentThread.GetApartmentState()); // test requires STA - this.asyncLock.Complete(); + this.asyncLock.Complete(); - // Exceptions should always be thrown via the awaitable result rather than synchronously thrown - // so that we meet expectations of C# async methods. - AsyncReaderWriterLock.Awaiter? awaiter = this.asyncLock.ReadLockAsync().GetAwaiter(); - Assert.True(awaiter.IsCompleted); - try - { - awaiter.GetResult(); - Assert.Fail("Expected exception not thrown."); - } - catch (InvalidOperationException) - { - } + // Exceptions should always be thrown via the awaitable result rather than synchronously thrown + // so that we meet expectations of C# async methods. + AsyncReaderWriterLock.Awaiter? awaiter = this.asyncLock.ReadLockAsync().GetAwaiter(); + Assert.True(awaiter.IsCompleted); + try + { + awaiter.GetResult(); + Assert.Fail("Expected exception not thrown."); + } + catch (InvalidOperationException) + { + } + }); } [Fact] @@ -3682,42 +3707,45 @@ await Task.WhenAll( })); } - [StaFact] + [Fact] public void OnBeforeWriteLockReleasedCallbackNeverInvokedOnSTA() { - TestUtilities.Run(async delegate + TestUtilities.RunOnSta(() => { - var callbackCompleted = new TaskCompletionSource(); - AsyncReaderWriterLock.Releaser releaser = default(AsyncReaderWriterLock.Releaser); - var staScheduler = TaskScheduler.FromCurrentSynchronizationContext(); - var nowait = Task.Run(async delegate + TestUtilities.Run(async delegate { - using (await this.asyncLock.UpgradeableReadLockAsync()) + var callbackCompleted = new TaskCompletionSource(); + AsyncReaderWriterLock.Releaser releaser = default(AsyncReaderWriterLock.Releaser); + var staScheduler = TaskScheduler.FromCurrentSynchronizationContext(); + var nowait = Task.Run(async delegate { - using (releaser = await this.asyncLock.WriteLockAsync()) + using (await this.asyncLock.UpgradeableReadLockAsync()) { - this.asyncLock.OnBeforeWriteLockReleased(async delegate + using (releaser = await this.asyncLock.WriteLockAsync()) { - try - { - Assert.Equal(ApartmentState.MTA, Thread.CurrentThread.GetApartmentState()); - await Task.Yield(); - Assert.Equal(ApartmentState.MTA, Thread.CurrentThread.GetApartmentState()); - await callbackCompleted.SetAsync(); - } - catch (Exception ex) + this.asyncLock.OnBeforeWriteLockReleased(async delegate { - callbackCompleted.SetException(ex); - } - }); + try + { + Assert.Equal(ApartmentState.MTA, Thread.CurrentThread.GetApartmentState()); + await Task.Yield(); + Assert.Equal(ApartmentState.MTA, Thread.CurrentThread.GetApartmentState()); + await callbackCompleted.SetAsync(); + } + catch (Exception ex) + { + callbackCompleted.SetException(ex); + } + }); - // Transition to an STA thread prior to calling Release (the point of this test). - await staScheduler; + // Transition to an STA thread prior to calling Release (the point of this test). + await staScheduler; + } } - } - }); + }); - await callbackCompleted.Task; + await callbackCompleted.Task; + }); }); } @@ -3725,66 +3753,69 @@ public void OnBeforeWriteLockReleasedCallbackNeverInvokedOnSTA() /// Test for when the write queue is NOT empty when a write lock is released on an STA to a (non-sticky) /// upgradeable read lock and a synchronous callback is to be invoked. /// - [StaFact] + [Fact] public async Task OnBeforeWriteLockReleasedToUpgradeableReadOnStaWithCallbacksAndWaitingWriter() { - TestUtilities.Run(async delegate + await TestUtilities.RunOnSta(async () => { - var firstWriteHeld = new TaskCompletionSource(); - var callbackCompleted = new TaskCompletionSource(); - var secondWriteLockQueued = new TaskCompletionSource(); - var secondWriteLockHeld = new TaskCompletionSource(); - AsyncReaderWriterLock.Releaser releaser = default(AsyncReaderWriterLock.Releaser); - var staScheduler = TaskScheduler.FromCurrentSynchronizationContext(); - await Task.WhenAll( - Task.Run(async delegate - { - using (await this.asyncLock.UpgradeableReadLockAsync()) + TestUtilities.Run(async delegate + { + var firstWriteHeld = new TaskCompletionSource(); + var callbackCompleted = new TaskCompletionSource(); + var secondWriteLockQueued = new TaskCompletionSource(); + var secondWriteLockHeld = new TaskCompletionSource(); + AsyncReaderWriterLock.Releaser releaser = default(AsyncReaderWriterLock.Releaser); + var staScheduler = TaskScheduler.FromCurrentSynchronizationContext(); + await Task.WhenAll( + Task.Run(async delegate { - using (releaser = await this.asyncLock.WriteLockAsync()) + using (await this.asyncLock.UpgradeableReadLockAsync()) { - await firstWriteHeld.SetAsync(); - this.asyncLock.OnBeforeWriteLockReleased(async delegate + using (releaser = await this.asyncLock.WriteLockAsync()) { - await callbackCompleted.SetAsync(); - }); + await firstWriteHeld.SetAsync(); + this.asyncLock.OnBeforeWriteLockReleased(async delegate + { + await callbackCompleted.SetAsync(); + }); - await secondWriteLockQueued.Task; + await secondWriteLockQueued.Task; - // Transition to an STA thread prior to calling Release (the point of this test). - await staScheduler; + // Transition to an STA thread prior to calling Release (the point of this test). + await staScheduler; + } } - } - }), - Task.Run(async delegate - { - await firstWriteHeld.Task; - AsyncReaderWriterLock.Awaiter? writerAwaiter = this.asyncLock.WriteLockAsync().GetAwaiter(); - Assert.False(writerAwaiter.IsCompleted); - writerAwaiter.OnCompleted(delegate + }), + Task.Run(async delegate { - using (writerAwaiter.GetResult()) + await firstWriteHeld.Task; + AsyncReaderWriterLock.Awaiter? writerAwaiter = this.asyncLock.WriteLockAsync().GetAwaiter(); + Assert.False(writerAwaiter.IsCompleted); + writerAwaiter.OnCompleted(delegate { - try + using (writerAwaiter.GetResult()) { - Assert.True(callbackCompleted.Task.IsCompleted); - secondWriteLockHeld.SetAsync(); - } - catch (Exception ex) - { - secondWriteLockHeld.SetException(ex); + try + { + Assert.True(callbackCompleted.Task.IsCompleted); + secondWriteLockHeld.SetAsync(); + } + catch (Exception ex) + { + secondWriteLockHeld.SetException(ex); + } } - } - }); + }); - await secondWriteLockQueued.SetAsync(); - }), - callbackCompleted.Task, - secondWriteLockHeld.Task); - }); + await secondWriteLockQueued.SetAsync(); + }), + callbackCompleted.Task, + secondWriteLockHeld.Task); + }); - this.asyncLock.Complete(); - await this.asyncLock.Completion; + this.asyncLock.Complete(); + await this.asyncLock.Completion; + }); } [Fact] @@ -3809,35 +3840,38 @@ public async Task OnBeforeWriteLockReleasedAndReenterConcurrency() } /// Verifies that locks requested on STA threads will marshal to an MTA. - [StaFact] + [Fact] public async Task StaLockRequestsMarshalToMTA() { - var testComplete = new TaskCompletionSource(); - Thread staThread = new Thread((ThreadStart)delegate + await TestUtilities.RunOnSta(async () => { - try + var testComplete = new TaskCompletionSource(); + Thread staThread = new Thread((ThreadStart)delegate { - AsyncReaderWriterLock.Awaitable awaitable = this.asyncLock.ReadLockAsync(); - AsyncReaderWriterLock.Awaiter? awaiter = awaitable.GetAwaiter(); - Assert.False(awaiter.IsCompleted, "The lock should not be issued on an STA thread."); - - awaiter.OnCompleted(delegate + try { - Assert.Equal(ApartmentState.MTA, Thread.CurrentThread.GetApartmentState()); - awaiter.GetResult().Dispose(); - testComplete.SetAsync(); - }); + AsyncReaderWriterLock.Awaitable awaitable = this.asyncLock.ReadLockAsync(); + AsyncReaderWriterLock.Awaiter? awaiter = awaitable.GetAwaiter(); + Assert.False(awaiter.IsCompleted, "The lock should not be issued on an STA thread."); - testComplete.Task.Wait(); - } - catch (Exception ex) - { - testComplete.TrySetException(ex); - } + awaiter.OnCompleted(delegate + { + Assert.Equal(ApartmentState.MTA, Thread.CurrentThread.GetApartmentState()); + awaiter.GetResult().Dispose(); + testComplete.SetAsync(); + }); + + testComplete.Task.Wait(); + } + catch (Exception ex) + { + testComplete.TrySetException(ex); + } + }); + staThread.SetApartmentState(ApartmentState.STA); + staThread.Start(); + await testComplete.Task; }); - staThread.SetApartmentState(ApartmentState.STA); - staThread.Start(); - await testComplete.Task; } /// Verifies that when an MTA holding a lock traverses (via CallContext) to an MTA that the MTA DOES appear to hold a lock. @@ -4322,6 +4356,9 @@ public async Task DisposeWhileExclusiveLockContextCaptured() } [Fact] +#if NET10_0_OR_GREATER + [UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "Tests deliberately exercise best-effort hang reports, including when trimmed.")] +#endif public void GetHangReportSimple() { IHangReportContributor reportContributor = this.asyncLock; @@ -4533,6 +4570,9 @@ private async Task DisposeWhileExclusiveLockContextCaptured_HelperAsync(AsyncMan await Task.Yield(); } +#if NET10_0_OR_GREATER + [UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "Tests deliberately exercise best-effort hang reports, including when trimmed.")] +#endif private void PrintHangReport() { IHangReportContributor reportContributor = this.asyncLock; diff --git a/test/Microsoft.VisualStudio.Threading.Tests/AwaitExtensionsTests.cs b/test/Microsoft.VisualStudio.Threading.Tests/AwaitExtensionsTests.cs index a120ec547..fb31575c7 100644 --- a/test/Microsoft.VisualStudio.Threading.Tests/AwaitExtensionsTests.cs +++ b/test/Microsoft.VisualStudio.Threading.Tests/AwaitExtensionsTests.cs @@ -27,7 +27,9 @@ public enum NamedSyncContext B, } - [Theory, CombinatorialData] + [Theory] + [InlineData(false)] + [InlineData(true)] public void TaskYield_ConfigureAwait_OnDispatcher(bool useDefaultYield) { this.ExecuteOnDispatcher(async delegate @@ -53,7 +55,9 @@ public void TaskYield_ConfigureAwait_OnDispatcher(bool useDefaultYield) }); } - [Theory, CombinatorialData] + [Theory] + [InlineData(false)] + [InlineData(true)] public void TaskYield_ConfigureAwait_OnDefaultSyncContext(bool useDefaultYield) { Task.Run(async delegate @@ -80,7 +84,9 @@ public void TaskYield_ConfigureAwait_OnDefaultSyncContext(bool useDefaultYield) }); } - [Theory, CombinatorialData] + [Theory] + [InlineData(false)] + [InlineData(true)] public void TaskYield_ConfigureAwait_OnNonDefaultTaskScheduler(bool useDefaultYield) { var scheduler = new MockTaskScheduler(); @@ -110,7 +116,9 @@ async delegate scheduler).Unwrap().GetAwaiter().GetResult(); } - [Theory, CombinatorialData] + [Theory] + [InlineData(false)] + [InlineData(true)] public void TaskYield_ConfigureAwait_OnDefaultTaskScheduler(bool useDefaultYield) { Task.Run( @@ -136,7 +144,9 @@ async delegate }).GetAwaiter().GetResult(); } - [Theory, CombinatorialData] + [Theory] + [InlineData(false)] + [InlineData(true)] public async Task TaskYield_ConfigureAwait_OnCompleted_CapturesExecutionContext(bool captureContext) { var taskResultSource = new TaskCompletionSource(); @@ -214,7 +224,15 @@ public void AwaitThreadPoolSchedulerNoYieldOnThreadPool() } [Theory] - [CombinatorialData] + [InlineData(NamedSyncContext.None, NamedSyncContext.None)] + [InlineData(NamedSyncContext.None, NamedSyncContext.A)] + [InlineData(NamedSyncContext.None, NamedSyncContext.B)] + [InlineData(NamedSyncContext.A, NamedSyncContext.None)] + [InlineData(NamedSyncContext.A, NamedSyncContext.A)] + [InlineData(NamedSyncContext.A, NamedSyncContext.B)] + [InlineData(NamedSyncContext.B, NamedSyncContext.None)] + [InlineData(NamedSyncContext.B, NamedSyncContext.A)] + [InlineData(NamedSyncContext.B, NamedSyncContext.B)] public void ConfigureAwaitRunInline_NoExtraThreadSwitching(NamedSyncContext invokeOn, NamedSyncContext completeOn) { // Set up various SynchronizationContexts that we may invoke or complete the async method with. @@ -250,7 +268,15 @@ async Task AwaitThenGetThreadAsync(Task antecedent) } [Theory] - [CombinatorialData] + [InlineData(NamedSyncContext.None, NamedSyncContext.None)] + [InlineData(NamedSyncContext.None, NamedSyncContext.A)] + [InlineData(NamedSyncContext.None, NamedSyncContext.B)] + [InlineData(NamedSyncContext.A, NamedSyncContext.None)] + [InlineData(NamedSyncContext.A, NamedSyncContext.A)] + [InlineData(NamedSyncContext.A, NamedSyncContext.B)] + [InlineData(NamedSyncContext.B, NamedSyncContext.None)] + [InlineData(NamedSyncContext.B, NamedSyncContext.A)] + [InlineData(NamedSyncContext.B, NamedSyncContext.B)] public void ConfigureAwaitRunInlineOfT_NoExtraThreadSwitching(NamedSyncContext invokeOn, NamedSyncContext completeOn) { // Set up various SynchronizationContexts that we may invoke or complete the async method with. @@ -337,7 +363,9 @@ public async Task AwaitTaskScheduler_UnsafeOnCompleted_DoesNotCaptureExecutionCo await taskResultSource.Task; } - [Theory, CombinatorialData] + [Theory] + [InlineData(false)] + [InlineData(true)] public async Task AwaitTaskScheduler_OnCompleted_CapturesExecutionContext(bool defaultTaskScheduler) { var taskResultSource = new TaskCompletionSource(); @@ -475,7 +503,8 @@ public async Task ConfigureAwaitForAggregateException_WithoutThrowing() } [Theory] - [CombinatorialData] + [InlineData(false)] + [InlineData(true)] public void ConfigureAwaitForAggregateException_OnDispatcher(bool continueOnCapturedContext) { this.ExecuteOnDispatcher(async delegate @@ -790,11 +819,7 @@ public async Task AwaitRegKeyChange_CallingThreadDestroyed() public async Task AwaitRegKeyChange_DoesNotPreventAppTerminationOnWin7() { Assert.SkipUnless(RuntimeInformation.IsOSPlatform(OSPlatform.Windows), "Windows only"); - string testExePath = Path.GetFullPath(Path.Combine( - AppDomain.CurrentDomain.BaseDirectory!, - "..", - "..", - "..", + string relativeTestExePath = Path.Combine( "Microsoft.VisualStudio.Threading.Tests.Win7RegistryWatcher", #if DEBUG "Debug", @@ -802,7 +827,15 @@ public async Task AwaitRegKeyChange_DoesNotPreventAppTerminationOnWin7() "Release", #endif "net472", - "Microsoft.VisualStudio.Threading.Tests.Win7RegistryWatcher.exe")); + "Microsoft.VisualStudio.Threading.Tests.Win7RegistryWatcher.exe"); + DirectoryInfo? directory = new DirectoryInfo(AppDomain.CurrentDomain.BaseDirectory!); + while (directory is not null && !File.Exists(Path.Combine(directory.FullName, relativeTestExePath))) + { + directory = directory.Parent; + } + + Assert.NotNull(directory); + string testExePath = Path.Combine(directory.FullName, relativeTestExePath); this.Logger.WriteLine("Using testexe path: {0}", testExePath); var psi = new ProcessStartInfo(testExePath) { diff --git a/test/Microsoft.VisualStudio.Threading.Tests/CancellationTokenExtensionsTests.cs b/test/Microsoft.VisualStudio.Threading.Tests/CancellationTokenExtensionsTests.cs index 2d483b9ec..b72f7a991 100644 --- a/test/Microsoft.VisualStudio.Threading.Tests/CancellationTokenExtensionsTests.cs +++ b/test/Microsoft.VisualStudio.Threading.Tests/CancellationTokenExtensionsTests.cs @@ -179,7 +179,8 @@ public void CombineWith_Array_TwoCancelable() } [Theory] - [CombinatorialData] + [InlineData(false)] + [InlineData(true)] public void CombineWith_Array_TwoCancelable_AmidMany_FirstOriginal(bool cancelFirst) { var cts1 = new CancellationTokenSource(); @@ -202,7 +203,8 @@ public void CombineWith_Array_TwoCancelable_AmidMany_FirstOriginal(bool cancelFi } [Theory] - [CombinatorialData] + [InlineData(false)] + [InlineData(true)] public void CombineWith_Array_TwoCancelable_AmidMany(bool cancelFirst) { var cts1 = new CancellationTokenSource(); @@ -224,8 +226,11 @@ public void CombineWith_Array_TwoCancelable_AmidMany(bool cancelFirst) } } - [Theory, CombinatorialData] - public void CombineWith_Array_ThreeCancelable_AmidMany([CombinatorialRange(0, 3)] int canceledIndex) + [Theory] + [InlineData(0)] + [InlineData(1)] + [InlineData(2)] + public void CombineWith_Array_ThreeCancelable_AmidMany(int canceledIndex) { CancellationTokenSource[] cts = new CancellationTokenSource[3]; for (int i = 0; i < 3; i++) diff --git a/test/Microsoft.VisualStudio.Threading.Tests/JoinableTaskAndAsyncReaderWriterLockTests.cs b/test/Microsoft.VisualStudio.Threading.Tests/JoinableTaskAndAsyncReaderWriterLockTests.cs index 33a233bfc..139606047 100644 --- a/test/Microsoft.VisualStudio.Threading.Tests/JoinableTaskAndAsyncReaderWriterLockTests.cs +++ b/test/Microsoft.VisualStudio.Threading.Tests/JoinableTaskAndAsyncReaderWriterLockTests.cs @@ -21,12 +21,17 @@ public JoinableTaskAndAsyncReaderWriterLockTests(ITestOutputHelper logger) InitializeJoinableTaskFactory(out this.joinableCollection, out this.asyncPump); } - [StaFact] + [Fact] public void LockWithinRunSTA() { - this.asyncPump.Run(async delegate + TestUtilities.RunOnSta(() => { - await this.VerifyReadLockAsync(); + InitializeJoinableTaskFactory(out this.joinableCollection, out this.asyncPump); + Assert.True(this.asyncPump.Context.IsOnMainThread); + this.asyncPump.Run(async delegate + { + await this.VerifyReadLockAsync(); + }); }); } @@ -55,13 +60,18 @@ public void LockWithinRunMTAContended() }).WaitWithoutInlining(throwOriginalException: true); } - [StaFact] + [Fact] public void LockWithinRunAfterYieldSTA() { - this.asyncPump.Run(async delegate + TestUtilities.RunOnSta(() => { - await Task.Yield(); - await this.VerifyReadLockAsync(); + InitializeJoinableTaskFactory(out this.joinableCollection, out this.asyncPump); + Assert.True(this.asyncPump.Context.IsOnMainThread); + this.asyncPump.Run(async delegate + { + await Task.Yield(); + await this.VerifyReadLockAsync(); + }); }); } @@ -78,10 +88,15 @@ public void LockWithinRunAfterYieldMTA() }).WaitWithoutInlining(throwOriginalException: true); } - [StaFact] + [Fact] public void LockWithinRunAsyncAfterYieldSTA() { - this.LockWithinRunAsyncAfterYieldHelper(); + TestUtilities.RunOnSta(() => + { + InitializeJoinableTaskFactory(out this.joinableCollection, out this.asyncPump); + Assert.True(this.asyncPump.Context.IsOnMainThread); + this.LockWithinRunAsyncAfterYieldHelper(); + }); } [Fact] diff --git a/test/Microsoft.VisualStudio.Threading.Tests/JoinableTaskContextTests.cs b/test/Microsoft.VisualStudio.Threading.Tests/JoinableTaskContextTests.cs index b644fc158..1230ae299 100644 --- a/test/Microsoft.VisualStudio.Threading.Tests/JoinableTaskContextTests.cs +++ b/test/Microsoft.VisualStudio.Threading.Tests/JoinableTaskContextTests.cs @@ -3,6 +3,7 @@ using System; using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; using System.Linq; using System.Threading; using System.Threading.Tasks; @@ -383,6 +384,9 @@ public void HangReportNotSuppressedOnLongRunningTaskCancelled() } [Fact] +#if NET10_0_OR_GREATER + [UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "Tests deliberately exercise best-effort hang reports, including when trimmed.")] +#endif public void GetHangReportSimple() { IHangReportContributor contributor = this.Context; @@ -396,6 +400,9 @@ public void GetHangReportSimple() } [Fact] +#if NET10_0_OR_GREATER + [UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "Tests deliberately exercise best-effort hang reports, including when trimmed.")] +#endif public void GetHangReportProducesDgmlWithNamedJoinableCollections() { const string jtcName = "My Collection"; @@ -416,6 +423,9 @@ where node.Attribute(XName.Get("Category"))?.Value == "Collection" } [Fact] +#if NET10_0_OR_GREATER + [UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "Tests deliberately exercise best-effort hang reports, including when trimmed.")] +#endif public void GetHangReportProducesDgmlWithMethodNameRequestingMainThread() { var mainThreadRequested = new ManualResetEventSlim(); @@ -437,6 +447,9 @@ where node.Attribute(XName.Get("Category"))?.Value == "Task" } [Fact(Skip = "Sadly, it seems JoinableTaskFactory.Post can't effectively override the labeled delegate because of another wrapper generated by the compiler.")] +#if NET10_0_OR_GREATER + [UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "Tests deliberately exercise best-effort hang reports, including when trimmed.")] +#endif public void GetHangReportProducesDgmlWithMethodNameYieldingOnMainThread() { this.ExecuteOnDispatcher(async delegate @@ -461,6 +474,9 @@ where node.Attribute(XName.Get("Category"))?.Value == "Task" } [Fact] +#if NET10_0_OR_GREATER + [UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "Tests deliberately exercise best-effort hang reports, including when trimmed.")] +#endif public void GetHangReportWithActualHang() { var endTestTokenSource = new CancellationTokenSource(); diff --git a/test/Microsoft.VisualStudio.Threading.Tests/JoinableTaskTestBase.cs b/test/Microsoft.VisualStudio.Threading.Tests/JoinableTaskTestBase.cs index bd64031a9..a659045d8 100644 --- a/test/Microsoft.VisualStudio.Threading.Tests/JoinableTaskTestBase.cs +++ b/test/Microsoft.VisualStudio.Threading.Tests/JoinableTaskTestBase.cs @@ -3,6 +3,7 @@ using System; using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; using System.Linq; using System.Runtime.ExceptionServices; using System.Threading; @@ -47,6 +48,9 @@ protected virtual JoinableTaskContext CreateJoinableTaskContext() return new JoinableTaskContext(); } +#if NET10_0_OR_GREATER + [UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "Tests deliberately exercise best-effort hang reports, including when trimmed.")] +#endif protected int GetPendingTasksCount() { IHangReportContributor hangContributor = this.context; diff --git a/test/Microsoft.VisualStudio.Threading.Tests/JoinableTaskTests.cs b/test/Microsoft.VisualStudio.Threading.Tests/JoinableTaskTests.cs index 7264640fc..ad701d175 100644 --- a/test/Microsoft.VisualStudio.Threading.Tests/JoinableTaskTests.cs +++ b/test/Microsoft.VisualStudio.Threading.Tests/JoinableTaskTests.cs @@ -18,10 +18,13 @@ public JoinableTaskTests(ITestOutputHelper logger) { } - [StaFact] + [Fact] public void RunFuncOfTaskSTA() { - this.RunFuncOfTaskHelper(); + TestUtilities.RunOnSta(() => + { + new JoinableTaskTests(this.Logger).RunFuncOfTaskHelper(); + }); } [Fact] @@ -4544,6 +4547,9 @@ private void RunFuncOfTaskOfTHelper() /// Writes out a DGML graph of pending tasks and collections to the test context. /// /// A specific context to collect data from; will use this.context. +#if NET10_0_OR_GREATER + [System.Diagnostics.CodeAnalysis.UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "Tests deliberately exercise best-effort hang reports, including when trimmed.")] +#endif private void PrintActiveTasksReport(JoinableTaskContext? context = null) { context = context ?? this.context; diff --git a/test/Microsoft.VisualStudio.Threading.Tests/JoinableTaskTokenTests.cs b/test/Microsoft.VisualStudio.Threading.Tests/JoinableTaskTokenTests.cs index 7d2e07360..e8da9c3f5 100644 --- a/test/Microsoft.VisualStudio.Threading.Tests/JoinableTaskTokenTests.cs +++ b/test/Microsoft.VisualStudio.Threading.Tests/JoinableTaskTokenTests.cs @@ -112,7 +112,9 @@ public void RunAsync_AfterParentCompletes() JoinableTaskCreationOptions.None).Join(); } - [Theory, PairwiseData] + [Theory] + [InlineData(false)] + [InlineData(true)] public async Task RunAsync_AvoidsDeadlockWithParent(bool includeOtherContexts) { string? parentToken = includeOtherContexts ? "abc:dead;ghi:coffee" : null; @@ -161,7 +163,9 @@ async delegate outerTask.Join(this.TimeoutToken); } - [Theory, PairwiseData] + [Theory] + [InlineData(false)] + [InlineData(true)] public async Task RunAsyncOfT_AvoidsDeadlockWithParent(bool includeOtherContexts) { string? parentToken = includeOtherContexts ? "abc:dead;ghi:coffee" : null; diff --git a/test/Microsoft.VisualStudio.Threading.Tests/Microsoft.VisualStudio.Threading.Tests.csproj b/test/Microsoft.VisualStudio.Threading.Tests/Microsoft.VisualStudio.Threading.Tests.csproj index d92df35cd..c89dfa97e 100644 --- a/test/Microsoft.VisualStudio.Threading.Tests/Microsoft.VisualStudio.Threading.Tests.csproj +++ b/test/Microsoft.VisualStudio.Threading.Tests/Microsoft.VisualStudio.Threading.Tests.csproj @@ -1,7 +1,9 @@ -๏ปฟ + + - net8.0 + net8.0;net10.0 Exe + false true true @@ -9,6 +11,9 @@ $(DefineConstants);ISOLATED_TEST_SUPPORT + + true + $(TargetFrameworks);net472;net8.0-windows @@ -29,10 +34,10 @@ - - - - + + + + @@ -48,4 +53,9 @@ true + + + + true + diff --git a/test/Microsoft.VisualStudio.Threading.Tests/ProgressWithCompletionTests.cs b/test/Microsoft.VisualStudio.Threading.Tests/ProgressWithCompletionTests.cs index 41f1c29f3..5251c88b8 100644 --- a/test/Microsoft.VisualStudio.Threading.Tests/ProgressWithCompletionTests.cs +++ b/test/Microsoft.VisualStudio.Threading.Tests/ProgressWithCompletionTests.cs @@ -125,7 +125,8 @@ public void SynchronizationContextCaptured() } [Theory] - [PairwiseData] + [InlineData(false)] + [InlineData(true)] public void DoesNotDeadlockWhenCallbackCapturesSyncContext(bool captureMainThreadContext) { SynchronizationContext? syncContext = SingleThreadedTestSynchronizationContext.New(); diff --git a/test/Microsoft.VisualStudio.Threading.Tests/TestBaseTests.cs b/test/Microsoft.VisualStudio.Threading.Tests/TestBaseTests.cs index 26ef0eb7f..291133049 100644 --- a/test/Microsoft.VisualStudio.Threading.Tests/TestBaseTests.cs +++ b/test/Microsoft.VisualStudio.Threading.Tests/TestBaseTests.cs @@ -39,21 +39,24 @@ public void ExecuteOnSTA_PropagatesExceptions() })); } - [StaFact] + [Fact] public void ExecuteOnDispatcher_ExecutesDelegateOnSTA() { - bool executed = false; - this.ExecuteOnDispatcher(delegate + TestUtilities.RunOnSta(() => { - if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + bool executed = false; + this.ExecuteOnDispatcher(delegate { - Assert.Equal(ApartmentState.STA, Thread.CurrentThread.GetApartmentState()); - } + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + Assert.Equal(ApartmentState.STA, Thread.CurrentThread.GetApartmentState()); + } - Assert.NotNull(SynchronizationContext.Current); - executed = true; + Assert.NotNull(SynchronizationContext.Current); + executed = true; + }); + Assert.True(executed); }); - Assert.True(executed); } [Fact] diff --git a/test/Microsoft.VisualStudio.Threading.Tests/TestUtilities.cs b/test/Microsoft.VisualStudio.Threading.Tests/TestUtilities.cs index 1c7872500..412e050c6 100644 --- a/test/Microsoft.VisualStudio.Threading.Tests/TestUtilities.cs +++ b/test/Microsoft.VisualStudio.Threading.Tests/TestUtilities.cs @@ -7,6 +7,7 @@ using System.Linq; using System.Reflection; using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; using System.Threading; using System.Threading.Tasks; using Microsoft; @@ -14,6 +15,26 @@ internal static class TestUtilities { + internal static void RunOnSta(Action action) => RunOnSta(() => + { + action(); + return Task.CompletedTask; + }).GetAwaiter().GetResult(); + + internal static Task RunOnSta(Func action) + { + Assert.SkipUnless(RuntimeInformation.IsOSPlatform(OSPlatform.Windows), "STA threads require Windows."); + return RunOnThread(action, singleThreadedContext: false); + } + + internal static void RunOnUIThread(Action action) => RunOnUIThread(() => + { + action(); + return Task.CompletedTask; + }).GetAwaiter().GetResult(); + + internal static Task RunOnUIThread(Func action) => RunOnThread(action, singleThreadedContext: true); + internal static Task SetAsync(this TaskCompletionSource tcs) { return Task.Run(() => tcs.TrySetResult(null)); @@ -42,7 +63,9 @@ internal static void Run(Func func) } SingleThreadedTestSynchronizationContext.IFrame? frame = SingleThreadedTestSynchronizationContext.NewFrame(); - t.ContinueWith(_ => { frame.Continue = false; }, TaskScheduler.Default); + + // The frame cannot be stopped until PushFrame assigns its owner. + t.ContinueWith(_ => syncCtx.Post(_ => frame.Continue = false, null), TaskScheduler.Default); SingleThreadedTestSynchronizationContext.PushFrame(syncCtx, frame); t.GetAwaiter().GetResult(); @@ -301,6 +324,40 @@ internal static T GetResultWithoutInlining(this Task task, bool throwOrigi return task.Result; } + private static Task RunOnThread(Func action, bool singleThreadedContext) + { + var completion = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var thread = new Thread(() => + { + try + { + if (singleThreadedContext) + { + Run(action); + } + else + { + action().GetAwaiter().GetResult(); + } + + completion.SetResult(null); + } + catch (Exception ex) + { + completion.SetException(ex); + } + }); + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { +#pragma warning disable CA1416 // RuntimeInformation guard supports the .NET Framework target too. + thread.SetApartmentState(ApartmentState.STA); +#pragma warning restore CA1416 + } + + thread.Start(); + return completion.Task; + } + private static string AssemblyCommandLineArguments(params string[] args) => string.Join(" ", args.Select(a => $"\"{a}\"")); internal readonly struct YieldAndNotifyAwaitable diff --git a/test/Microsoft.VisualStudio.Threading.Tests/TestUtilitiesTests.cs b/test/Microsoft.VisualStudio.Threading.Tests/TestUtilitiesTests.cs index 82a530f1d..a7aa8d8e9 100644 --- a/test/Microsoft.VisualStudio.Threading.Tests/TestUtilitiesTests.cs +++ b/test/Microsoft.VisualStudio.Threading.Tests/TestUtilitiesTests.cs @@ -1,10 +1,72 @@ ๏ปฟ// Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT license. See LICENSE file in the project root for full license information. +using System; +using System.Threading; using System.Threading.Tasks; public class TestUtilitiesTests { + [Fact] + public void RunOnSta_UsesStaWithoutSynchronizationContext() + { + TestUtilities.RunOnSta(() => + { + Assert.Equal(ApartmentState.STA, Thread.CurrentThread.GetApartmentState()); + Assert.Null(SynchronizationContext.Current); + }); + } + + [Fact] + public async Task RunOnUIThread_PreservesThreadAffinityAndExecutionContext() + { + var ambient = new System.Threading.AsyncLocal { Value = new object() }; + object expected = ambient.Value; + int callingThread = Environment.CurrentManagedThreadId; + await TestUtilities.RunOnUIThread(async () => + { + int thread = Environment.CurrentManagedThreadId; + Assert.NotEqual(callingThread, thread); + Assert.Same(expected, ambient.Value); + Assert.NotNull(SynchronizationContext.Current); + await Task.Yield(); + Assert.Equal(thread, Environment.CurrentManagedThreadId); + Assert.Same(expected, ambient.Value); + }); + } + + [Fact] + public async Task RunOnUIThread_CompletesSynchronousActions() + { + for (int i = 0; i < 100; i++) + { + bool executed = false; + Task completion = Task.Run(() => TestUtilities.RunOnUIThread((Action)(() => executed = true))); + Assert.Same(completion, await Task.WhenAny(completion, Task.Delay(TimeSpan.FromSeconds(5)))); + await completion; + Assert.True(executed); + } + } + + [Fact] + public void RunOnUIThread_PropagatesSynchronousExceptions() + { + var expected = new InvalidOperationException(); + Assert.Same(expected, Assert.Throws(() => TestUtilities.RunOnUIThread((Action)(() => throw expected)))); + } + + [Fact] + public async Task RunOnUIThread_PropagatesAsynchronousExceptions() + { + var expected = new InvalidOperationException(); + Exception actual = await Assert.ThrowsAsync(() => TestUtilities.RunOnUIThread(async () => + { + await Task.Yield(); + throw expected; + })); + Assert.Same(expected, actual); + } + [Fact] public void RunTest() { diff --git a/test/Microsoft.VisualStudio.Threading.Tests/ThreadingToolsTests.cs b/test/Microsoft.VisualStudio.Threading.Tests/ThreadingToolsTests.cs index a670da2c9..f28b79596 100644 --- a/test/Microsoft.VisualStudio.Threading.Tests/ThreadingToolsTests.cs +++ b/test/Microsoft.VisualStudio.Threading.Tests/ThreadingToolsTests.cs @@ -244,36 +244,45 @@ public void WithCancellationNoncancelableNoDeadlockFromSyncContext() WithCancellationSyncBlockOnNoncancelableToken(); } - [UIFact] + [Fact] public void WithCancellationNoDeadlockFromSyncContextWithinJTFRun_Canceled() { - var jtc = new JoinableTaskContext(); - jtc.Factory.Run(delegate + TestUtilities.RunOnUIThread(() => { - WithCancellationSyncBlock(simulateCancellation: true); - return Task.CompletedTask; + var jtc = new JoinableTaskContext(); + jtc.Factory.Run(delegate + { + WithCancellationSyncBlock(simulateCancellation: true); + return Task.CompletedTask; + }); }); } - [UIFact] + [Fact] public void WithCancellationNoDeadlockFromSyncContextWithinJTFRun_Completed() { - var jtc = new JoinableTaskContext(); - jtc.Factory.Run(delegate + TestUtilities.RunOnUIThread(() => { - WithCancellationSyncBlock(simulateCancellation: false); - return Task.CompletedTask; + var jtc = new JoinableTaskContext(); + jtc.Factory.Run(delegate + { + WithCancellationSyncBlock(simulateCancellation: false); + return Task.CompletedTask; + }); }); } - [UIFact] + [Fact] public void WithCancellationNoncancelableNoDeadlockFromSyncContextWithinJTFRun() { - var jtc = new JoinableTaskContext(); - jtc.Factory.Run(delegate + TestUtilities.RunOnUIThread(() => { - WithCancellationSyncBlockOnNoncancelableToken(); - return Task.CompletedTask; + var jtc = new JoinableTaskContext(); + jtc.Factory.Run(delegate + { + WithCancellationSyncBlockOnNoncancelableToken(); + return Task.CompletedTask; + }); }); } diff --git a/test/Microsoft.VisualStudio.Threading.Tests/TplExtensionsTests.cs b/test/Microsoft.VisualStudio.Threading.Tests/TplExtensionsTests.cs index 4d0429637..35358ee37 100644 --- a/test/Microsoft.VisualStudio.Threading.Tests/TplExtensionsTests.cs +++ b/test/Microsoft.VisualStudio.Threading.Tests/TplExtensionsTests.cs @@ -350,7 +350,8 @@ public async Task NoThrowAwaitable() /// is captured and used to schedule the continuation, the is always captured and applied. /// [Theory] - [CombinatorialData] + [InlineData(false)] + [InlineData(true)] public async Task NoThrowAwaitable_Await_CapturesExecutionContext(bool captureContext) { var awaitableTcs = new TaskCompletionSource(); @@ -373,7 +374,8 @@ public async Task NoThrowAwaitable_Await_CapturesExecutionContext(bool captureCo /// is captured and used to schedule the continuation, the is always captured and applied. /// [Theory] - [CombinatorialData] + [InlineData(false)] + [InlineData(true)] public async Task NoThrowAwaitable_OnCompleted_CapturesExecutionContext(bool captureContext) { var testResultTcs = new TaskCompletionSource(); @@ -401,7 +403,8 @@ public async Task NoThrowAwaitable_OnCompleted_CapturesExecutionContext(bool cap } [Theory] - [CombinatorialData] + [InlineData(false)] + [InlineData(true)] public async Task NoThrowAwaitable_UnsafeOnCompleted_DoesNotCaptureExecutionContext(bool captureContext) { var testResultTcs = new TaskCompletionSource(); @@ -449,7 +452,8 @@ public async Task NoThrowAwaitable_ValueTask() /// is captured and used to schedule the continuation, the is always captured and applied. /// [Theory] - [CombinatorialData] + [InlineData(false)] + [InlineData(true)] public async Task NoThrowAwaitable_ValueTask_Await_CapturesExecutionContext(bool captureContext) { var awaitableTcs = new TaskCompletionSource(); @@ -472,7 +476,8 @@ public async Task NoThrowAwaitable_ValueTask_Await_CapturesExecutionContext(bool /// is captured and used to schedule the continuation, the is always captured and applied. /// [Theory] - [CombinatorialData] + [InlineData(false)] + [InlineData(true)] public async Task NoThrowAwaitable_ValueTask_OnCompleted_CapturesExecutionContext(bool captureContext) { var testResultTcs = new TaskCompletionSource(); @@ -500,7 +505,8 @@ public async Task NoThrowAwaitable_ValueTask_OnCompleted_CapturesExecutionContex } [Theory] - [CombinatorialData] + [InlineData(false)] + [InlineData(true)] public async Task NoThrowAwaitable_ValueTask_UnsafeOnCompleted_DoesNotCaptureExecutionContext(bool captureContext) { var testResultTcs = new TaskCompletionSource(); @@ -596,7 +602,8 @@ public async Task NoThrowAwaitable_ValueTaskT() /// is captured and used to schedule the continuation, the is always captured and applied. /// [Theory] - [CombinatorialData] + [InlineData(false)] + [InlineData(true)] public async Task NoThrowAwaitable_ValueTaskT_Await_CapturesExecutionContext(bool captureContext) { var awaitableTcs = new TaskCompletionSource(); @@ -619,7 +626,8 @@ public async Task NoThrowAwaitable_ValueTaskT_Await_CapturesExecutionContext(boo /// is captured and used to schedule the continuation, the is always captured and applied. /// [Theory] - [CombinatorialData] + [InlineData(false)] + [InlineData(true)] public async Task NoThrowAwaitable_ValueTaskT_OnCompleted_CapturesExecutionContext(bool captureContext) { var testResultTcs = new TaskCompletionSource(); @@ -647,7 +655,8 @@ public async Task NoThrowAwaitable_ValueTaskT_OnCompleted_CapturesExecutionConte } [Theory] - [CombinatorialData] + [InlineData(false)] + [InlineData(true)] public async Task NoThrowAwaitable_ValueTaskT_UnsafeOnCompleted_DoesNotCaptureExecutionContext(bool captureContext) { var testResultTcs = new TaskCompletionSource(); diff --git a/test/Microsoft.VisualStudio.Threading.Tests/ValidityTests.cs b/test/Microsoft.VisualStudio.Threading.Tests/ValidityTests.cs index 176e1de45..636ee56c7 100644 --- a/test/Microsoft.VisualStudio.Threading.Tests/ValidityTests.cs +++ b/test/Microsoft.VisualStudio.Threading.Tests/ValidityTests.cs @@ -12,8 +12,10 @@ public class ValidityTests [Fact] public void ProductNotInGac() { +#if NETFRAMEWORK Assert.False( typeof(AsyncBarrier).GetTypeInfo().Assembly.Location.Contains("GAC"), $"{typeof(AsyncBarrier).GetTypeInfo().Assembly.GetName().Name} was loaded from the GAC. Run UnGac.cmd."); +#endif } } diff --git a/test/NativeAOTCompatibility.Test/NativeAOTCompatibility.Test.csproj b/test/NativeAOTCompatibility.Test/NativeAOTCompatibility.Test.csproj index 1c29d7486..721d0fe6c 100644 --- a/test/NativeAOTCompatibility.Test/NativeAOTCompatibility.Test.csproj +++ b/test/NativeAOTCompatibility.Test/NativeAOTCompatibility.Test.csproj @@ -1,5 +1,4 @@ - net10.0 false @@ -8,6 +7,7 @@ $(TargetFrameworks);net10.0-windows + diff --git a/test/dirs.proj b/test/dirs.proj index dde4d2640..76e0f1e4a 100644 --- a/test/dirs.proj +++ b/test/dirs.proj @@ -2,7 +2,20 @@ - - + + + + + + + + + + + diff --git a/tools/Configure-GitHubOidc.ps1 b/tools/Configure-GitHubOidc.ps1 new file mode 100644 index 000000000..a48e739cc --- /dev/null +++ b/tools/Configure-GitHubOidc.ps1 @@ -0,0 +1,189 @@ +#!/usr/bin/env pwsh + +<# +.SYNOPSIS + Configures immutable GitHub OIDC subjects and matching Entra federated credentials. +.DESCRIPTION + Run from the repository to configure, after signing in with gh auth login and + az login --tenant 72f988bf-86f1-41af-91ab-2d7cd011db47 --allow-no-subscriptions. + Requires repository administration permission and permission to manage federated + credentials on the Entra application. This changes the subject format for ALL + OIDC workflows in the repository; migrate any other cloud trust policies first. + Existing federated credentials are preserved. Feed permissions are not changed. +.PARAMETER ApplicationId + The Entra application (client) ID to configure. +.PARAMETER TenantId + The tenant in which the application is registered. The active az account must + belong to this tenant. +.PARAMETER Branches + Additional branches to trust. Pull requests and the repository's default + branch are always included. +.EXAMPLE + ./tools/Configure-GitHubOidc.ps1 -WhatIf +.EXAMPLE + ./tools/Configure-GitHubOidc.ps1 -Branches main,microbuild,release +#> +[CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'Medium')] +Param( + [guid]$ApplicationId = '2799af29-63f3-404f-bdcf-67ff9c70abc9', + [guid]$TenantId = '72f988bf-86f1-41af-91ab-2d7cd011db47', + [ValidateNotNullOrEmpty()] + [string[]]$Branches +) + +$ErrorActionPreference = 'Stop' + +function Invoke-CheckedCommand { + Param( + [string]$Command, + [string[]]$Arguments + ) + + $output = & $Command @Arguments + if ($LASTEXITCODE -ne 0) { + throw "$Command $($Arguments -join ' ') failed with exit code $LASTEXITCODE." + } + + return $output +} + +function Invoke-JsonCommand { + Param( + [string]$Command, + [string[]]$Arguments, + $Body + ) + + # JSON files avoid native-command quoting differences between Windows and Unix. + $path = [System.IO.Path]::GetTempFileName() + try { + $Body | ConvertTo-Json -Depth 10 | Set-Content -LiteralPath $path -Encoding utf8 + Invoke-CheckedCommand $Command ($Arguments + $path) + } + finally { + Remove-Item -LiteralPath $path -Force + } +} + +Get-Command gh, az -ErrorAction Stop | Out-Null +$repoName = (Invoke-CheckedCommand gh @('repo', 'view', '--json', 'nameWithOwner') | ConvertFrom-Json).nameWithOwner +if ([string]::IsNullOrWhiteSpace($repoName)) { + throw 'gh did not identify a repository in the current directory.' +} + +$repo = Invoke-CheckedCommand gh @('api', "repos/$repoName") | ConvertFrom-Json +if (!$repo.id -or !$repo.owner.id -or !$repo.owner.login -or !$repo.name -or !$repo.default_branch) { + throw 'GitHub returned incomplete repository identity information.' +} + +$oidcEndpoint = "repos/$($repo.full_name)/actions/oidc/customization/sub" +$currentSettings = Invoke-CheckedCommand gh @('api', $oidcEndpoint) | ConvertFrom-Json +$account = Invoke-CheckedCommand az @('account', 'show', '--output', 'json') | ConvertFrom-Json +if ($account.tenantId -ne $TenantId.ToString()) { + throw "Sign in to tenant $TenantId with az login --tenant $TenantId --allow-no-subscriptions before running this script." +} + +$issuer = 'https://token.actions.githubusercontent.com' +$audience = 'api://AzureADTokenExchange' +$prefix = "repo:$($repo.owner.login)@$($repo.owner.id)/$($repo.name)@$($repo.id)" +$trustedBranches = @($repo.default_branch) +if ($PSBoundParameters.ContainsKey('Branches')) { + $trustedBranches += $Branches +} + +$contexts = @('pull_request') +foreach ($branch in $trustedBranches) { + if ([string]::IsNullOrWhiteSpace($branch)) { + throw 'Branch names must not be empty or whitespace.' + } + + $contexts += "ref:refs/heads/$($branch.Replace(':', '%3A'))" +} + +$existing = @(Invoke-CheckedCommand az @('ad', 'app', 'federated-credential', 'list', '--id', $ApplicationId.ToString(), '--output', 'json') | ConvertFrom-Json) +$missing = @() +foreach ($context in ($contexts | Select-Object -Unique)) { + $subject = "${prefix}:$context" + $matching = @($existing | Where-Object { $_.issuer -ceq $issuer -and $_.subject -ceq $subject }) + if ($matching.Count -gt 0) { + if (!($matching | Where-Object { @($_.audiences).Count -eq 1 -and $_.audiences[0] -ceq $audience })) { + throw "An existing credential for '$subject' has an unexpected audience. Resolve it before continuing." + } + + Write-Host "Reusing federated credential '$($matching[0].name)' for '$subject'." + continue + } + + $contextName = if ($context -eq 'pull_request') { + 'pull-request' + } else { + "branch-$($context.Substring('ref:refs/heads/'.Length).Replace('%3A', ':'))" + } + $readableName = "github-$($repo.owner.login)-$($repo.name)-$contextName" + $name = $readableName -creplace '[^A-Za-z0-9._-]', '-' + # Entra names must be URL-friendly and at most 120 characters. Preserve uniqueness + # when replacing branch punctuation or shortening a long name. + if ($name -cne $readableName -or $name.Length -gt 120) { + $sha256 = [System.Security.Cryptography.SHA256]::Create() + try { + $hash = $sha256.ComputeHash([System.Text.Encoding]::UTF8.GetBytes($subject)) + $suffix = ([System.BitConverter]::ToString($hash)).Replace('-', '').Substring(0, 16).ToLowerInvariant() + } + finally { + $sha256.Dispose() + } + + $name = "$($name.Substring(0, [Math]::Min($name.Length, 103)))-$suffix" + } + + if (@($existing) + @($missing) | Where-Object { $_.name -eq $name }) { + throw "Federated credential name '$name' is already used for a different trust. No existing credentials were modified." + } + + $missing += @{ + name = $name + issuer = $issuer + subject = $subject + audiences = @($audience) + description = "GitHub Actions for $($repo.full_name): $context" + } +} + +$settingsMatch = $currentSettings.use_default -eq $true -and $currentSettings.use_immutable_subject -eq $true -and $currentSettings.sub_claim_prefix -ceq $prefix +if ($missing.Count -eq 0 -and $settingsMatch) { + Write-Host "Immutable OIDC subjects and federated credentials are already configured for $($repo.full_name)." + return +} + +Write-Warning 'Changing the repository OIDC subject format affects every workflow, including existing environment and other cloud trust policies.' +if (!$PSCmdlet.ShouldProcess("$($repo.full_name) and Entra application $ApplicationId", "Ensure $($missing.Count) federated credentials and enable immutable default OIDC subjects")) { + return +} + +foreach ($credential in $missing) { + Invoke-JsonCommand az @('ad', 'app', 'federated-credential', 'create', '--id', $ApplicationId.ToString(), '--output', 'none', '--parameters') $credential | Out-Null + Write-Host "Created federated credential '$($credential.name)' for '$($credential.subject)'." +} + +$credentials = @(Invoke-CheckedCommand az @('ad', 'app', 'federated-credential', 'list', '--id', $ApplicationId.ToString(), '--output', 'json') | ConvertFrom-Json) +foreach ($context in ($contexts | Select-Object -Unique)) { + $subject = "${prefix}:$context" + if (!($credentials | Where-Object { $_.issuer -ceq $issuer -and $_.subject -ceq $subject -and @($_.audiences).Count -eq 1 -and $_.audiences[0] -ceq $audience })) { + throw "Federated credential verification failed for '$subject'." + } +} + +# Verify Azure trust before switching GitHub to the new subject format. +if (!$settingsMatch) { + Invoke-JsonCommand gh @('api', '--method', 'PUT', $oidcEndpoint, '--input') @{ + use_default = $true + use_immutable_subject = $true + } | Out-Null +} + +$settings = Invoke-CheckedCommand gh @('api', $oidcEndpoint) | ConvertFrom-Json +if ($settings.use_default -ne $true -or $settings.use_immutable_subject -ne $true -or $settings.sub_claim_prefix -cne $prefix) { + throw 'GitHub OIDC settings did not match the requested immutable default subject format.' +} + +Write-Host "Configured immutable OIDC subjects and federated credentials for $($repo.full_name)." diff --git a/tools/Get-NativeAOTTestProjects.ps1 b/tools/Get-NativeAOTTestProjects.ps1 new file mode 100644 index 000000000..27a39ec39 --- /dev/null +++ b/tools/Get-NativeAOTTestProjects.ps1 @@ -0,0 +1,25 @@ +#!/usr/bin/env pwsh + +<# +.SYNOPSIS + Gets NativeAOT test executable paths evaluated by the test traversal project. +.PARAMETER Configuration + The build configuration used to evaluate output paths. +#> +[CmdletBinding()] +Param( + [string]$Configuration='Debug' +) + +$result = & dotnet msbuild "$PSScriptRoot/../test/dirs.proj" ` + -nologo ` + -getTargetResult:GetNativeAOTTests ` + "-property:Configuration=$Configuration" +if ($LASTEXITCODE -ne 0) { + throw 'Failed to discover NativeAOT tests through MSBuild.' +} + +$tests = ($result | Out-String | ConvertFrom-Json).TargetResults.GetNativeAOTTests.Items +foreach ($test in $tests) { + $test +} diff --git a/tools/Install-DotNetSdk.ps1 b/tools/Install-DotNetSdk.ps1 index 2621dda15..6fdb09a31 100644 --- a/tools/Install-DotNetSdk.ps1 +++ b/tools/Install-DotNetSdk.ps1 @@ -21,6 +21,8 @@ Installs a x86 SDK and runtimes in addition to the x64 ones. Only supported on Windows. Ignored on others. .PARAMETER IncludeAspNetCore Installs the ASP.NET Core runtime along with the .NET runtime. +.PARAMETER Upgrade + Checks for newer runtime and SDK channel patches even when a matching installation exists. #> [CmdletBinding(SupportsShouldProcess=$true,ConfirmImpact='Medium')] Param ( @@ -28,7 +30,8 @@ Param ( [string]$InstallLocality='user', [switch]$SdkOnly, [switch]$IncludeX86, - [switch]$IncludeAspNetCore + [switch]$IncludeAspNetCore, + [switch]$Upgrade ) $DotNetInstallScriptRoot = "$PSScriptRoot/../obj/tools" @@ -198,6 +201,88 @@ Function Install-DotNet($Version, $Architecture, [ValidateSet('Sdk','Runtime','W } } +Function Test-DotNetRuntimeInstalled( + $DotNetRoot, + [ValidateSet('Microsoft.NETCore.App','Microsoft.WindowsDesktop.App','Microsoft.AspNetCore.App')] + [string]$Runtime, + [Version]$Version +) { + if ($Upgrade -or !$DotNetRoot) { + return $false + } + + $runtimeRoot = Join-Path $DotNetRoot "shared\$Runtime" + if (!(Test-Path -LiteralPath $runtimeRoot)) { + return $false + } + + foreach ($runtimeDirectory in Get-ChildItem -LiteralPath $runtimeRoot -Directory) { + $installedVersion = $null + if ([Version]::TryParse($runtimeDirectory.Name, [ref]$installedVersion) -and + $Version.Major -eq $installedVersion.Major -and + $Version.Minor -eq $installedVersion.Minor -and + (($Version.Build -eq -1) -or ($Version.Build -eq $installedVersion.Build)) -and + (($Version.Revision -eq -1) -or ($Version.Revision -eq $installedVersion.Revision))) { + return $true + } + } + + return $false +} + +Function Test-DotNetSdkInstalled($DotNetRoot, $Version, $Channel) { + if (!$DotNetRoot) { + return $false + } + + $sdkRoot = Join-Path $DotNetRoot 'sdk' + if (!(Test-Path -LiteralPath $sdkRoot)) { + return $false + } + + if ($Version) { + return Test-Path -LiteralPath (Join-Path $sdkRoot $Version) -PathType Container + } + + if ($Upgrade) { + return $false + } + + $channelVersion = $null + if (![Version]::TryParse($Channel, [ref]$channelVersion)) { + return $false + } + + foreach ($sdkDirectory in Get-ChildItem -LiteralPath $sdkRoot -Directory) { + $installedVersion = $null + if ([Version]::TryParse($sdkDirectory.Name, [ref]$installedVersion) -and + $channelVersion.Major -eq $installedVersion.Major -and + $channelVersion.Minor -eq $installedVersion.Minor) { + return $true + } + } + + return $false +} + +Function Get-MachineDotNetInstallDir([string]$Architecture) { + # The native Program Files directory, even when this is a 32-bit process on 64-bit Windows. + $nativeProgramFiles = if ($env:ProgramW6432) { $env:ProgramW6432 } else { $env:ProgramFiles } + $isArm64Windows = [bool]${env:ProgramFiles(Arm)} + switch ($Architecture) { + 'x86' { + if (${env:ProgramFiles(x86)}) { Join-Path ${env:ProgramFiles(x86)} 'dotnet' } else { Join-Path $nativeProgramFiles 'dotnet' } + } + 'x64' { + # On ARM64 Windows, the x64 .NET installers use a dotnet\x64 subdirectory. + if ($isArm64Windows) { Join-Path $nativeProgramFiles 'dotnet\x64' } else { Join-Path $nativeProgramFiles 'dotnet' } + } + default { + Join-Path $nativeProgramFiles 'dotnet' + } + } +} + $switches = @() $envVars = @{ # For locally installed dotnet, skip first time experience which takes a long time @@ -208,53 +293,68 @@ if ($InstallLocality -eq 'machine') { if ($IsMacOS -or $IsLinux) { $DotNetInstallDir = '/usr/share/dotnet' } else { + # Check for installed components where the installers for each architecture put them. + $DotNetInstallDir = Get-MachineDotNetInstallDir -Architecture $arch + $DotNetX86InstallDir = Get-MachineDotNetInstallDir -Architecture x86 $restartRequired = $false $sdks |% { if ($_.Version) { $version = $_.Version } else { $version = $_.Channel } - if ($PSCmdlet.ShouldProcess(".NET SDK $version ($arch)", "Install")) { + if (!(Test-DotNetSdkInstalled -DotNetRoot $DotNetInstallDir -Version $_.Version -Channel $_.Channel) -and + $PSCmdlet.ShouldProcess(".NET SDK $version ($arch)", "Install")) { Install-DotNet -Version $version -Architecture $arch $restartRequired = $restartRequired -or ($LASTEXITCODE -eq 3010) + } - if ($IncludeX86) { - Install-DotNet -Version $version -Architecture x86 - $restartRequired = $restartRequired -or ($LASTEXITCODE -eq 3010) - } + if ($IncludeX86 -and + !(Test-DotNetSdkInstalled -DotNetRoot $DotNetX86InstallDir -Version $_.Version -Channel $_.Channel) -and + $PSCmdlet.ShouldProcess(".NET SDK $version (x86)", "Install")) { + Install-DotNet -Version $version -Architecture x86 + $restartRequired = $restartRequired -or ($LASTEXITCODE -eq 3010) } } - $runtimeVersions | Sort-Object | Get-Unique |% { - if ($PSCmdlet.ShouldProcess(".NET runtime $_", "Install")) { + $runtimeVersions | Sort-Object -Unique |% { + if (!(Test-DotNetRuntimeInstalled -DotNetRoot $DotNetInstallDir -Runtime Microsoft.NETCore.App -Version $_) -and + $PSCmdlet.ShouldProcess(".NET runtime $_ ($arch)", "Install")) { Install-DotNet -Version $_ -sku Runtime -Architecture $arch $restartRequired = $restartRequired -or ($LASTEXITCODE -eq 3010) + } - if ($IncludeX86) { - Install-DotNet -Version $_ -sku Runtime -Architecture x86 - $restartRequired = $restartRequired -or ($LASTEXITCODE -eq 3010) - } + if ($IncludeX86 -and + !(Test-DotNetRuntimeInstalled -DotNetRoot $DotNetX86InstallDir -Runtime Microsoft.NETCore.App -Version $_) -and + $PSCmdlet.ShouldProcess(".NET runtime $_ (x86)", "Install")) { + Install-DotNet -Version $_ -sku Runtime -Architecture x86 + $restartRequired = $restartRequired -or ($LASTEXITCODE -eq 3010) } } - $windowsDesktopRuntimeVersions | Sort-Object | Get-Unique |% { - if ($PSCmdlet.ShouldProcess(".NET Windows Desktop $_", "Install")) { + $windowsDesktopRuntimeVersions | Sort-Object -Unique |% { + if (!(Test-DotNetRuntimeInstalled -DotNetRoot $DotNetInstallDir -Runtime Microsoft.WindowsDesktop.App -Version $_) -and + $PSCmdlet.ShouldProcess(".NET Windows Desktop $_ ($arch)", "Install")) { Install-DotNet -Version $_ -sku WindowsDesktop -Architecture $arch $restartRequired = $restartRequired -or ($LASTEXITCODE -eq 3010) + } - if ($IncludeX86) { - Install-DotNet -Version $_ -sku WindowsDesktop -Architecture x86 - $restartRequired = $restartRequired -or ($LASTEXITCODE -eq 3010) - } + if ($IncludeX86 -and + !(Test-DotNetRuntimeInstalled -DotNetRoot $DotNetX86InstallDir -Runtime Microsoft.WindowsDesktop.App -Version $_) -and + $PSCmdlet.ShouldProcess(".NET Windows Desktop $_ (x86)", "Install")) { + Install-DotNet -Version $_ -sku WindowsDesktop -Architecture x86 + $restartRequired = $restartRequired -or ($LASTEXITCODE -eq 3010) } } - $aspnetRuntimeVersions | Sort-Object | Get-Unique |% { - if ($PSCmdlet.ShouldProcess("ASP.NET Core $_", "Install")) { + $aspnetRuntimeVersions | Sort-Object -Unique |% { + if (!(Test-DotNetRuntimeInstalled -DotNetRoot $DotNetInstallDir -Runtime Microsoft.AspNetCore.App -Version $_) -and + $PSCmdlet.ShouldProcess("ASP.NET Core $_ ($arch)", "Install")) { Install-DotNet -Version $_ -sku AspNetCore -Architecture $arch $restartRequired = $restartRequired -or ($LASTEXITCODE -eq 3010) + } - if ($IncludeX86) { - Install-DotNet -Version $_ -sku AspNetCore -Architecture x86 - $restartRequired = $restartRequired -or ($LASTEXITCODE -eq 3010) - } + if ($IncludeX86 -and + !(Test-DotNetRuntimeInstalled -DotNetRoot $DotNetX86InstallDir -Runtime Microsoft.AspNetCore.App -Version $_) -and + $PSCmdlet.ShouldProcess("ASP.NET Core $_ (x86)", "Install")) { + Install-DotNet -Version $_ -sku AspNetCore -Architecture x86 + $restartRequired = $restartRequired -or ($LASTEXITCODE -eq 3010) } } if ($restartRequired) { @@ -317,19 +417,21 @@ $global:LASTEXITCODE = 0 $sdks |% { if ($_.Version) { $parameters = '-Version', $_.Version } else { $parameters = '-Channel', $_.Channel } - if ($PSCmdlet.ShouldProcess(".NET SDK $_ ($arch)", "Install")) { - $anythingInstalled = $true - Invoke-Expression -Command "$DotNetInstallScriptPathExpression $parameters -Architecture $arch -InstallDir $DotNetInstallDir $switches" + if (!(Test-DotNetSdkInstalled -DotNetRoot $DotNetInstallDir -Version $_.Version -Channel $_.Channel)) { + if ($PSCmdlet.ShouldProcess(".NET SDK $_ ($arch)", "Install")) { + $anythingInstalled = $true + Invoke-Expression -Command "$DotNetInstallScriptPathExpression $parameters -Architecture $arch -InstallDir $DotNetInstallDir $switches" - if ($LASTEXITCODE -ne 0) { - Write-Error ".NET SDK installation failure: $LASTEXITCODE" - exit $LASTEXITCODE + if ($LASTEXITCODE -ne 0) { + Write-Error ".NET SDK installation failure: $LASTEXITCODE" + exit $LASTEXITCODE + } + } else { + Invoke-Expression -Command "$DotNetInstallScriptPathExpression $parameters -Architecture $arch -InstallDir $DotNetInstallDir $switches -DryRun" } - } else { - Invoke-Expression -Command "$DotNetInstallScriptPathExpression $parameters -Architecture $arch -InstallDir $DotNetInstallDir $switches -DryRun" } - if ($IncludeX86) { + if ($IncludeX86 -and !(Test-DotNetSdkInstalled -DotNetRoot $DotNetX86InstallDir -Version $_.Version -Channel $_.Channel)) { if ($PSCmdlet.ShouldProcess(".NET x86 SDK $_", "Install")) { $anythingInstalled = $true Invoke-Expression -Command "$DotNetInstallScriptPathExpression $parameters -Architecture x86 -InstallDir $DotNetX86InstallDir $switches" @@ -347,19 +449,21 @@ $sdks |% { $dotnetRuntimeSwitches = $switches + '-Runtime','dotnet' $runtimeVersions | Sort-Object -Unique |% { - if ($PSCmdlet.ShouldProcess(".NET $Arch runtime $_", "Install")) { - $anythingInstalled = $true - Invoke-Expression -Command "$DotNetInstallScriptPathExpression -Channel $_ -Architecture $arch -InstallDir $DotNetInstallDir $dotnetRuntimeSwitches" + if (!(Test-DotNetRuntimeInstalled -DotNetRoot $DotNetInstallDir -Runtime Microsoft.NETCore.App -Version $_)) { + if ($PSCmdlet.ShouldProcess(".NET $Arch runtime $_", "Install")) { + $anythingInstalled = $true + Invoke-Expression -Command "$DotNetInstallScriptPathExpression -Channel $_ -Architecture $arch -InstallDir $DotNetInstallDir $dotnetRuntimeSwitches" - if ($LASTEXITCODE -ne 0) { - Write-Error ".NET SDK installation failure: $LASTEXITCODE" - exit $LASTEXITCODE + if ($LASTEXITCODE -ne 0) { + Write-Error ".NET SDK installation failure: $LASTEXITCODE" + exit $LASTEXITCODE + } + } else { + Invoke-Expression -Command "$DotNetInstallScriptPathExpression -Channel $_ -Architecture $arch -InstallDir $DotNetInstallDir $dotnetRuntimeSwitches -DryRun" } - } else { - Invoke-Expression -Command "$DotNetInstallScriptPathExpression -Channel $_ -Architecture $arch -InstallDir $DotNetInstallDir $dotnetRuntimeSwitches -DryRun" } - if ($IncludeX86) { + if ($IncludeX86 -and !(Test-DotNetRuntimeInstalled -DotNetRoot $DotNetX86InstallDir -Runtime Microsoft.NETCore.App -Version $_)) { if ($PSCmdlet.ShouldProcess(".NET x86 runtime $_", "Install")) { $anythingInstalled = $true Invoke-Expression -Command "$DotNetInstallScriptPathExpression -Channel $_ -Architecture x86 -InstallDir $DotNetX86InstallDir $dotnetRuntimeSwitches" @@ -377,19 +481,21 @@ $runtimeVersions | Sort-Object -Unique |% { $windowsDesktopRuntimeSwitches = $switches + '-Runtime','windowsdesktop' $windowsDesktopRuntimeVersions | Sort-Object -Unique |% { - if ($PSCmdlet.ShouldProcess(".NET WindowsDesktop $arch runtime $_", "Install")) { - $anythingInstalled = $true - Invoke-Expression -Command "$DotNetInstallScriptPathExpression -Channel $_ -Architecture $arch -InstallDir $DotNetInstallDir $windowsDesktopRuntimeSwitches" + if (!(Test-DotNetRuntimeInstalled -DotNetRoot $DotNetInstallDir -Runtime Microsoft.WindowsDesktop.App -Version $_)) { + if ($PSCmdlet.ShouldProcess(".NET WindowsDesktop $arch runtime $_", "Install")) { + $anythingInstalled = $true + Invoke-Expression -Command "$DotNetInstallScriptPathExpression -Channel $_ -Architecture $arch -InstallDir $DotNetInstallDir $windowsDesktopRuntimeSwitches" - if ($LASTEXITCODE -ne 0) { - Write-Error ".NET SDK installation failure: $LASTEXITCODE" - exit $LASTEXITCODE + if ($LASTEXITCODE -ne 0) { + Write-Error ".NET SDK installation failure: $LASTEXITCODE" + exit $LASTEXITCODE + } + } else { + Invoke-Expression -Command "$DotNetInstallScriptPathExpression -Channel $_ -Architecture $arch -InstallDir $DotNetInstallDir $windowsDesktopRuntimeSwitches -DryRun" } - } else { - Invoke-Expression -Command "$DotNetInstallScriptPathExpression -Channel $_ -Architecture $arch -InstallDir $DotNetInstallDir $windowsDesktopRuntimeSwitches -DryRun" } - if ($IncludeX86) { + if ($IncludeX86 -and !(Test-DotNetRuntimeInstalled -DotNetRoot $DotNetX86InstallDir -Runtime Microsoft.WindowsDesktop.App -Version $_)) { if ($PSCmdlet.ShouldProcess(".NET WindowsDesktop x86 runtime $_", "Install")) { $anythingInstalled = $true Invoke-Expression -Command "$DotNetInstallScriptPathExpression -Channel $_ -Architecture x86 -InstallDir $DotNetX86InstallDir $windowsDesktopRuntimeSwitches" @@ -407,19 +513,21 @@ $windowsDesktopRuntimeVersions | Sort-Object -Unique |% { $aspnetRuntimeSwitches = $switches + '-Runtime','aspnetcore' $aspnetRuntimeVersions | Sort-Object -Unique |% { - if ($PSCmdlet.ShouldProcess(".NET ASP.NET Core $arch runtime $_", "Install")) { - $anythingInstalled = $true - Invoke-Expression -Command "$DotNetInstallScriptPathExpression -Channel $_ -Architecture $arch -InstallDir $DotNetInstallDir $aspnetRuntimeSwitches" + if (!(Test-DotNetRuntimeInstalled -DotNetRoot $DotNetInstallDir -Runtime Microsoft.AspNetCore.App -Version $_)) { + if ($PSCmdlet.ShouldProcess(".NET ASP.NET Core $arch runtime $_", "Install")) { + $anythingInstalled = $true + Invoke-Expression -Command "$DotNetInstallScriptPathExpression -Channel $_ -Architecture $arch -InstallDir $DotNetInstallDir $aspnetRuntimeSwitches" - if ($LASTEXITCODE -ne 0) { - Write-Error ".NET SDK installation failure: $LASTEXITCODE" - exit $LASTEXITCODE + if ($LASTEXITCODE -ne 0) { + Write-Error ".NET SDK installation failure: $LASTEXITCODE" + exit $LASTEXITCODE + } + } else { + Invoke-Expression -Command "$DotNetInstallScriptPathExpression -Channel $_ -Architecture $arch -InstallDir $DotNetInstallDir $aspnetRuntimeSwitches -DryRun" } - } else { - Invoke-Expression -Command "$DotNetInstallScriptPathExpression -Channel $_ -Architecture $arch -InstallDir $DotNetInstallDir $aspnetRuntimeSwitches -DryRun" } - if ($IncludeX86) { + if ($IncludeX86 -and !(Test-DotNetRuntimeInstalled -DotNetRoot $DotNetX86InstallDir -Runtime Microsoft.AspNetCore.App -Version $_)) { if ($PSCmdlet.ShouldProcess(".NET ASP.NET Core x86 runtime $_", "Install")) { $anythingInstalled = $true Invoke-Expression -Command "$DotNetInstallScriptPathExpression -Channel $_ -Architecture x86 -InstallDir $DotNetX86InstallDir $aspnetRuntimeSwitches" diff --git a/tools/dirs.proj b/tools/dirs.proj index 8f1d6787a..b37efc7dd 100644 --- a/tools/dirs.proj +++ b/tools/dirs.proj @@ -2,5 +2,6 @@ + diff --git a/tools/dotnet-test-cloud.ps1 b/tools/dotnet-test-cloud.ps1 index eafca35f1..0c7db92d7 100644 --- a/tools/dotnet-test-cloud.ps1 +++ b/tools/dotnet-test-cloud.ps1 @@ -5,6 +5,8 @@ Runs tests as they are run in cloud test runs. .PARAMETER Configuration The configuration within which to run tests +.PARAMETER IncludeNativeAOT + Runs the NativeAOT-compiled tests and fails if the expected image is missing. .PARAMETER Agent The name of the agent. This is used in preparing test run titles. .PARAMETER PublishResults @@ -19,6 +21,7 @@ [CmdletBinding()] Param( [string]$Configuration='Debug', + [switch]$IncludeNativeAOT, [string]$Agent='Local', [switch]$PublishResults, [switch]$x86, @@ -50,6 +53,9 @@ if ($x86) { $testBinLog = Join-Path $ArtifactStagingFolder (Join-Path build_logs test.binlog) $testLogs = Join-Path $ArtifactStagingFolder test_logs +if (Test-Path -LiteralPath $testLogs) { + Remove-Item -LiteralPath $testLogs -Recurse -Force +} $extraArgs = @() if ($IsLinux -or $IsMacOS) { @@ -74,6 +80,7 @@ if ($isMTP) { ,'--crash-report-if-supported' ) $mtpArgs = @( + ,'--filter-not-trait','TestCategory=FailsInCloudTest' ,'--diagnostic' ,'--diagnostic-output-directory',$testLogs ,'--diagnostic-verbosity','Information' @@ -100,12 +107,40 @@ if ($isMTP) { -c $Configuration ` -bl:"$testBinLog" ` -- ` - --filter-not-trait 'TestCategory=FailsInCloudTest' ` @mtpArgs ` @dumpSwitches ` @extraArgs if ($LASTEXITCODE -ne 0) { $failedTests += 1 } + if ($IncludeNativeAOT) { + $nativeAotTests = @(& "$PSScriptRoot/Get-NativeAOTTestProjects.ps1" -Configuration $Configuration) + foreach ($nativeAotTest in $nativeAotTests) { + $testExecutable = $nativeAotTest.ExecutablePath + if (-not (Test-Path -LiteralPath $testExecutable -PathType Leaf)) { + Write-Error "Expected NativeAOT test executable '$testExecutable' was not found." + $failedTests += 1 + continue + } + + $nativeAotArgs = @( + ,'--filter-not-trait','TestCategory=FailsInCloudTest' + ,'--diagnostic' + ,'--diagnostic-output-directory',$testLogs + ,'--diagnostic-verbosity','Information' + ,'--results-directory',$testLogs + ,'--report-trx' + ,'--report-trx-filename',"$($nativeAotTest.ProjectName)_$($nativeAotTest.TargetFramework)_NativeAOT_{arch}.trx" + ,'--timeout','5m' + ) + if ($env:OS -eq 'Windows_NT') { + $nativeAotArgs += $dumpSwitches + } + Write-Host "Running NativeAOT tests from '$testExecutable'." -ForegroundColor Cyan + & $testExecutable @nativeAotArgs @extraArgs + if ($LASTEXITCODE -ne 0) { $failedTests += 1 } + } + } + $trxFiles = Get-ChildItem -Recurse -Path $testLogs\*.trx } else { $testDiagLog = Join-Path $ArtifactStagingFolder (Join-Path test_logs diag.log) diff --git a/tools/tests/Configure-GitHubOidc.Tests.ps1 b/tools/tests/Configure-GitHubOidc.Tests.ps1 new file mode 100644 index 000000000..e24a02560 --- /dev/null +++ b/tools/tests/Configure-GitHubOidc.Tests.ps1 @@ -0,0 +1,200 @@ +#!/usr/bin/env pwsh + +# Run with pwsh -File tools/tests/Configure-GitHubOidc.Tests.ps1. No cloud changes are made. +$ErrorActionPreference = 'Stop' +$target = Join-Path $PSScriptRoot '..\Configure-GitHubOidc.ps1' +$tenant = '72f988bf-86f1-41af-91ab-2d7cd011db47' +$prefix = 'repo:ExampleOrg@123/ExampleRepo@456' + +function Assert-True($Condition, [string]$Message) { + if (!$Condition) { + throw $Message + } +} + +function Reset-State { + $script:state = @{ + Settings = @{ use_default = $true; use_immutable_subject = $false; sub_claim_prefix = 'repo:ExampleOrg/ExampleRepo' } + Credentials = @() + Writes = @() + Tenant = $tenant + Failure = '' + BadVerification = $false + MissingCreatedCredentials = $false + Prefix = $prefix + } + $global:ConfigureGitHubOidcTestState = $script:state +} + +function gh { + $state = $global:ConfigureGitHubOidcTestState + $global:LASTEXITCODE = 0 + if ($args[0] -eq 'repo') { + return '{"nameWithOwner":"ExampleOrg/ExampleRepo"}' + } + + if ($args[1] -eq 'repos/ExampleOrg/ExampleRepo') { + return '{"id":456,"name":"ExampleRepo","full_name":"ExampleOrg/ExampleRepo","owner":{"id":123,"login":"ExampleOrg"},"default_branch":"trunk"}' + } + + if ($args[1] -eq '--method') { + $state.Writes += 'gh-put' + if ($state.Failure -eq 'gh-put') { + $global:LASTEXITCODE = 1 + return + } + + $body = Get-Content -LiteralPath $args[-1] -Raw | ConvertFrom-Json + Assert-True ($body.use_default -eq $true -and $body.use_immutable_subject -eq $true) 'Incorrect GitHub settings payload.' + $state.Settings = @{ use_default = $true; use_immutable_subject = $true; sub_claim_prefix = $state.Prefix } + return + } + + Assert-True ($args[1] -eq 'repos/ExampleOrg/ExampleRepo/actions/oidc/customization/sub') 'Unexpected GitHub API endpoint.' + if ($state.BadVerification -and $state.Writes -contains 'gh-put') { + return '{"use_default":true,"use_immutable_subject":true,"sub_claim_prefix":"wrong"}' + } + + return ($state.Settings | ConvertTo-Json) +} + +function az { + $state = $global:ConfigureGitHubOidcTestState + $global:LASTEXITCODE = 0 + if ($args[0] -eq 'account') { + return (@{ tenantId = $state.Tenant } | ConvertTo-Json) + } + + Assert-True ($args[0] -eq 'ad' -and $args[1] -eq 'app' -and $args[2] -eq 'federated-credential') 'Unexpected Azure command.' + Assert-True ($args[5] -eq '2799af29-63f3-404f-bdcf-67ff9c70abc9') 'Incorrect application ID.' + if ($args[3] -eq 'list') { + return (ConvertTo-Json -InputObject @($state.Credentials) -Depth 10) + } + + Assert-True ($args[3] -eq 'create') 'Unexpected Azure mutation.' + $state.Writes += 'az-create' + if ($state.Failure -eq 'az-create') { + $global:LASTEXITCODE = 1 + return + } + + $body = Get-Content -LiteralPath $args[-1] -Raw | ConvertFrom-Json + Assert-True ($body.issuer -ceq 'https://token.actions.githubusercontent.com') 'Incorrect issuer.' + Assert-True ($body.audiences.Count -eq 1 -and $body.audiences[0] -ceq 'api://AzureADTokenExchange') 'Incorrect audience.' + if (!$state.MissingCreatedCredentials) { + $state.Credentials += $body + } +} + +function Assert-Fails([string]$Pattern, [hashtable]$Parameters = @{}) { + $failed = $false + try { + & $target @Parameters -WarningAction SilentlyContinue + } + catch { + Assert-True ($_.Exception.Message -like $Pattern) "Unexpected error: $($_.Exception.Message)" + $failed = $true + } + + Assert-True $failed 'Expected script to fail.' +} + +Reset-State +& $target -WarningAction SilentlyContinue +$subjects = @($script:state.Credentials.subject) +Assert-True ($subjects.Count -eq 2) 'Expected two federated credentials.' +Assert-True ($subjects -cnotcontains "${prefix}:ref:refs/heads/microbuild") 'microbuild must not be trusted by default.' +foreach ($context in @('pull_request', 'ref:refs/heads/trunk')) { + Assert-True ($subjects -ccontains "${prefix}:$context") "Missing subject: $context" +} +Assert-True (($script:state.Writes -join ',') -eq 'az-create,az-create,gh-put') 'Azure trust must be created before GitHub settings change.' +Assert-True (@($script:state.Credentials.name | Select-Object -Unique).Count -eq 2) 'Credential names must be unique.' +Assert-True ($script:state.Credentials.name -ccontains 'github-ExampleOrg-ExampleRepo-pull-request') 'PR credential name must be human-readable.' +Assert-True ($script:state.Credentials.name -ccontains 'github-ExampleOrg-ExampleRepo-branch-trunk') 'Branch credential name must be human-readable.' +$firstCredentialName = $script:state.Credentials[0].name + +$script:state.Writes = @() +& $target -WarningAction SilentlyContinue +Assert-True ($script:state.Writes.Count -eq 0) 'Rerun must not duplicate credentials or rewrite settings.' + +Reset-State +& $target -WhatIf -WarningAction SilentlyContinue +Assert-True ($script:state.Writes.Count -eq 0) 'WhatIf must not mutate either service.' + +Reset-State +& $target -Branches @('release', 'release') -WarningAction SilentlyContinue +Assert-True ($script:state.Credentials.Count -eq 3) 'Duplicate branches must be deduplicated while retaining the default branch.' +Assert-True ($script:state.Credentials.subject -ccontains "${prefix}:ref:refs/heads/release") 'Custom branch subject missing.' +Assert-True ($script:state.Credentials.subject -ccontains "${prefix}:ref:refs/heads/trunk") 'Explicit branches must not replace the default branch.' + +Reset-State +& $target -Branches microbuild -WarningAction SilentlyContinue +Assert-True ($script:state.Credentials.Count -eq 3) 'A single additional branch must produce PR, default branch, and additional branch credentials.' +Assert-True ($script:state.Credentials.subject -ccontains "${prefix}:ref:refs/heads/microbuild") 'Single-string branch argument must be trusted.' +Assert-True ($script:state.Credentials.subject -ccontains "${prefix}:ref:refs/heads/trunk") 'Default branch must be retained with a single-string argument.' + +Reset-State +& $target -Branches trunk -WarningAction SilentlyContinue +Assert-True ($script:state.Credentials.Count -eq 2) 'Explicit default branch must not duplicate default trust.' + +Reset-State +& $target -Branches @('feature/widget', 'feature-widget', ('long-' + ('x' * 150))) -WarningAction SilentlyContinue +$names = @($script:state.Credentials.name) +Assert-True (($names | Select-Object -Unique).Count -eq 5) 'Sanitized branch names must remain unique.' +foreach ($name in $names) { + Assert-True ($name.Length -le 120 -and $name -cmatch '^[A-Za-z0-9._-]+$') 'Credential names must satisfy Entra limits.' + Assert-True ($name.StartsWith('github-ExampleOrg-ExampleRepo-')) 'Sanitized names must retain repository names.' +} +Assert-True ($names -ccontains 'github-ExampleOrg-ExampleRepo-branch-feature-widget') 'URL-friendly branch names should not have a hash suffix.' +$script:state.Writes = @() +& $target -Branches @('feature/widget', 'feature-widget', ('long-' + ('x' * 150))) -WarningAction SilentlyContinue +Assert-True ($script:state.Writes.Count -eq 0) 'Sanitized names must remain idempotent.' + +Reset-State +$script:state.Credentials = @(@{ name = 'unrelated'; issuer = 'other'; subject = 'other'; audiences = @('other') }) +& $target -WarningAction SilentlyContinue +Assert-True ($script:state.Credentials[0].name -eq 'unrelated' -and $script:state.Credentials.Count -eq 3) 'Unrelated trust must be preserved.' + +Reset-State +$script:state.Tenant = 'wrong-tenant' +Assert-Fails 'Sign in to tenant*' +Assert-True ($script:state.Writes.Count -eq 0) 'Wrong tenant must fail before mutations.' + +Reset-State +$script:state.Failure = 'az-create' +Assert-Fails 'az *failed with exit code 1.' +Assert-True ($script:state.Writes -notcontains 'gh-put') 'Azure failure must prevent GitHub subject changes.' + +Reset-State +$script:state.Failure = 'gh-put' +Assert-Fails 'gh *failed with exit code 1.' +$script:state.Failure = '' +$script:state.Writes = @() +& $target -WarningAction SilentlyContinue +Assert-True (($script:state.Writes -join ',') -eq 'gh-put') 'Retry after GitHub failure must reuse newly created Azure credentials.' + +Reset-State +$script:state.Credentials = @(@{ name = 'wrong-audience'; issuer = 'https://token.actions.githubusercontent.com'; subject = "${prefix}:pull_request"; audiences = @('wrong') }) +Assert-Fails '*unexpected audience*' +Assert-True ($script:state.Writes.Count -eq 0) 'Conflicting audience must fail before mutations.' + +Reset-State +$script:state.Credentials = @(@{ name = $firstCredentialName; issuer = 'other'; subject = 'other'; audiences = @('other') }) +Assert-Fails '*already used for a different trust*' +Assert-True ($script:state.Writes.Count -eq 0) 'Credential name collisions must fail before mutations.' + +Reset-State +Assert-Fails 'Branch names must not be empty*' @{ Branches = @(' ') } +Assert-True ($script:state.Writes.Count -eq 0) 'Invalid branches must fail before mutations.' + +Reset-State +$script:state.BadVerification = $true +Assert-Fails 'GitHub OIDC settings did not match*' + +Reset-State +$script:state.MissingCreatedCredentials = $true +Assert-Fails 'Federated credential verification failed*' +Assert-True ($script:state.Writes -notcontains 'gh-put') 'Unverified Azure trust must prevent GitHub changes.' + +Write-Host 'Configure-GitHubOidc tests passed.' +Remove-Variable ConfigureGitHubOidcTestState -Scope Global diff --git a/tools/tests/DocsFeedCredentials.Tests.ps1 b/tools/tests/DocsFeedCredentials.Tests.ps1 new file mode 100644 index 000000000..2de06dd4b --- /dev/null +++ b/tools/tests/DocsFeedCredentials.Tests.ps1 @@ -0,0 +1,75 @@ +#!/usr/bin/env pwsh + +$ErrorActionPreference = 'Stop' +$workflow = Get-Content -LiteralPath (Join-Path $PSScriptRoot '..\..\.github\workflows\docs_validate.yml') -Raw +$match = [regex]::Match($workflow, '(?ms) - name: Configure Azure Artifacts credentials\r?\n.*? run: \|\r?\n(?