From 4009fb250788bbc3d1a7ad3bb9f77f21f7621c6c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Amaury=20Lev=C3=A9?= Date: Fri, 3 Jul 2026 17:11:02 +0200 Subject: [PATCH 1/8] Document GH_AW_GITHUB_TOKEN removal and enterprise 8-day PAT policy (#9588) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> (cherry picked from commit 0c3fc2be901d5dbbcafa3e83bac2515c4c95ba8c) --- .github/workflows/README.md | 20 ++++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/.github/workflows/README.md b/.github/workflows/README.md index e9977b4fb3..e816cd62aa 100644 --- a/.github/workflows/README.md +++ b/.github/workflows/README.md @@ -43,14 +43,22 @@ Agentic workflows authenticate through repository secrets: | Secret / permission | Used for | Notes | | --- | --- | --- | | `COPILOT_GITHUB_TOKEN` | GitHub Copilot CLI (model inference) | Fine-grained PAT. **Preferably replaced** by the `copilot-requests: write` permission — see below. | -| `GH_AW_GITHUB_TOKEN` | GitHub MCP reads / safe-output writes that need more than the default `GITHUB_TOKEN` | Fine-grained PAT and the token that used to be forced by lockdown mode. **Preferably replaced** by a GitHub App — see below. | +| `GH_AW_GITHUB_TOKEN` | GitHub MCP reads / safe-output writes that need more than the default `GITHUB_TOKEN` | Fine-grained PAT and the token that used to be forced by lockdown mode. **Preferably replaced** by a GitHub App — see below. **Currently unset**: the compiler's token chain (`GH_AW_GITHUB_MCP_SERVER_TOKEN` \|\| `GH_AW_GITHUB_TOKEN` \|\| `GITHUB_TOKEN`) falls back to the per-run `GITHUB_TOKEN` when this secret is absent, so leave it unset unless a workflow needs elevated access. | > [!IMPORTANT] -> **Fine-grained PATs expire and, under the `microsoft` org policy, may live at most ~1 week.** -> A PAT-based setup therefore breaks on a short cycle: when the token lapses, every workflow -> that depends on it fails at once and files a burst of `[aw] … failed` issues. Prefer the -> two PAT-free options below — together they let this repo run agentic workflows with **no -> long-lived PAT at all**. +> **Fine-grained PATs expire, and the `Microsoft Open Source` enterprise now hard-rejects any +> fine-grained PAT whose lifetime exceeds 8 days** (the API returns `403` with a message pointing +> at the token's settings page). A PAT-based setup therefore breaks on a short cycle: when the +> token lapses — or simply outlives the 8-day window — every workflow that depends on it fails at +> once (e.g. the `Checkout PR branch` step 403s on the collaborator-permission and PR lookups) and +> files a burst of `[aw] … failed` issues. Prefer the two PAT-free options below — together they +> let this repo run agentic workflows with **no long-lived PAT at all**. +> +> **Fast unblock:** delete the `GH_AW_GITHUB_TOKEN` secret (run `gh secret delete GH_AW_GITHUB_TOKEN --repo microsoft/testfx`). +> Because no source workflow forces a custom PAT anymore (`lockdown` was +> removed repo-wide and all declare `min-integrity: none`), every workflow then degrades gracefully +> to the built-in `GITHUB_TOKEN`. The only case that still needs elevated auth is a write-back on a +> **fork** PR (where `GITHUB_TOKEN` is read-only) — use the GitHub App below for those. ### Preferred: eliminate the expiring PATs From 4a0f5618e07b17a8f97e2f28fa6e27982d2eb618 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Amaury=20Lev=C3=A9?= Date: Sun, 5 Jul 2026 12:13:31 +0200 Subject: [PATCH 2/8] test: add edge case tests for MSTEST0045/0050/0060 analyzers (#9615) Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> (cherry picked from commit fe518a59cbfcb675c59b4c2055a237fb5c2dda4e) --- ...plicateTestMethodAttributeAnalyzerTests.cs | 75 +++++++++++++ ...alTestFixtureShouldBeValidAnalyzerTests.cs | 45 ++++++++ ...tiveCancellationForTimeoutAnalyzerTests.cs | 102 ++++++++++++++++++ 3 files changed, 222 insertions(+) diff --git a/test/UnitTests/MSTest.Analyzers.UnitTests/DuplicateTestMethodAttributeAnalyzerTests.cs b/test/UnitTests/MSTest.Analyzers.UnitTests/DuplicateTestMethodAttributeAnalyzerTests.cs index b86a0b9e31..ca4f9fd8ef 100644 --- a/test/UnitTests/MSTest.Analyzers.UnitTests/DuplicateTestMethodAttributeAnalyzerTests.cs +++ b/test/UnitTests/MSTest.Analyzers.UnitTests/DuplicateTestMethodAttributeAnalyzerTests.cs @@ -378,4 +378,79 @@ public void TestMethod1() await VerifyCS.VerifyCodeFixAsync(code, fixedCode); } + + [TestMethod] + public async Task WhenTestMethodHasDuplicateAttributesOutsideTestClass_Diagnostic() + { + // The analyzer fires on duplicate TestMethod-like attributes regardless of whether + // the containing class is marked [TestClass]. + string code = """ + using Microsoft.VisualStudio.TestTools.UnitTesting; + + public class NonTestClass + { + [TestMethod] + [DataTestMethod] + public void [|TestMethod1|]() + { + } + } + """; + + await VerifyCS.VerifyAnalyzerAsync(code); + } + + [TestMethod] + public async Task WhenDerivedAttributeIsFirstAndTestMethodIsSecond_CodeFix_KeepsDerivedAttribute() + { + // The fixer uses a "first-wins" strategy: iterates attribute lists in order and + // keeps the first TestMethod-derived attribute it encounters. When a derived + // attribute appears before [TestMethod], the derived one is retained. + string code = """ + using Microsoft.VisualStudio.TestTools.UnitTesting; + using System.Runtime.CompilerServices; + + public class MyTestMethod : TestMethodAttribute + { + public MyTestMethod([CallerFilePath] string callerFilePath = "", [CallerLineNumber] int callerLineNumber = -1) + : base(callerFilePath, callerLineNumber) + { + } + } + + [TestClass] + public class MyTestClass + { + [MyTestMethod] + [TestMethod] + public void [|TestMethod1|]() + { + } + } + """; + + string fixedCode = """ + using Microsoft.VisualStudio.TestTools.UnitTesting; + using System.Runtime.CompilerServices; + + public class MyTestMethod : TestMethodAttribute + { + public MyTestMethod([CallerFilePath] string callerFilePath = "", [CallerLineNumber] int callerLineNumber = -1) + : base(callerFilePath, callerLineNumber) + { + } + } + + [TestClass] + public class MyTestClass + { + [MyTestMethod] + public void TestMethod1() + { + } + } + """; + + await VerifyCS.VerifyCodeFixAsync(code, fixedCode); + } } diff --git a/test/UnitTests/MSTest.Analyzers.UnitTests/GlobalTestFixtureShouldBeValidAnalyzerTests.cs b/test/UnitTests/MSTest.Analyzers.UnitTests/GlobalTestFixtureShouldBeValidAnalyzerTests.cs index 74f754c64e..d6653e3e7b 100644 --- a/test/UnitTests/MSTest.Analyzers.UnitTests/GlobalTestFixtureShouldBeValidAnalyzerTests.cs +++ b/test/UnitTests/MSTest.Analyzers.UnitTests/GlobalTestFixtureShouldBeValidAnalyzerTests.cs @@ -298,4 +298,49 @@ public class MyTestClass await VerifyCS.VerifyCodeFixAsync(code, code); } + + [TestMethod] + public async Task WhenGlobalTestInitializeInGenericClass_Diagnostic() + { + // Generic containing type is rejected by `allowGenericType: false` in the analyzer. + string code = """ + using Microsoft.VisualStudio.TestTools.UnitTesting; + + [TestClass] + public class MyTestClass + { + [GlobalTestInitialize] + public static void [|GlobalTestInitialize|](TestContext testContext) + { + } + } + """; + + await VerifyCS.VerifyCodeFixAsync(code, code); + } + + [TestMethod] + public async Task WhenGlobalTestCleanupWithDerivedTestClassAttribute_NoDiagnostic() + { + // A class marked with a TestClassAttribute-derived attribute satisfies the TestClass check + // because the guard uses `Inherits(testClassAttributeSymbol)`. + string code = """ + using Microsoft.VisualStudio.TestTools.UnitTesting; + + public class MyTestClassAttribute : TestClassAttribute + { + } + + [MyTestClass] + public class MyTestClass + { + [GlobalTestCleanup] + public static void GlobalTestCleanup(TestContext testContext) + { + } + } + """; + + await VerifyCS.VerifyCodeFixAsync(code, code); + } } diff --git a/test/UnitTests/MSTest.Analyzers.UnitTests/UseCooperativeCancellationForTimeoutAnalyzerTests.cs b/test/UnitTests/MSTest.Analyzers.UnitTests/UseCooperativeCancellationForTimeoutAnalyzerTests.cs index af903fac6d..da94e17838 100644 --- a/test/UnitTests/MSTest.Analyzers.UnitTests/UseCooperativeCancellationForTimeoutAnalyzerTests.cs +++ b/test/UnitTests/MSTest.Analyzers.UnitTests/UseCooperativeCancellationForTimeoutAnalyzerTests.cs @@ -476,4 +476,106 @@ public void MyTestMethod() await VerifyCS.VerifyCodeFixAsync(code, fixedCode); } + + [TestMethod] + public async Task WhenTimeoutAttributeOnAsyncTestMethod_Diagnostic() + { + string code = """ + using System.Threading.Tasks; + using Microsoft.VisualStudio.TestTools.UnitTesting; + + [TestClass] + public class MyTestClass + { + [TestMethod] + [[|Timeout(5000)|]] + public async Task MyAsyncTestMethod() + { + await Task.Yield(); + } + } + """; + + string fixedCode = """ + using System.Threading.Tasks; + using Microsoft.VisualStudio.TestTools.UnitTesting; + + [TestClass] + public class MyTestClass + { + [TestMethod] + [Timeout(5000, CooperativeCancellation = true)] + public async Task MyAsyncTestMethod() + { + await Task.Yield(); + } + } + """; + + await VerifyCS.VerifyCodeFixAsync(code, fixedCode); + } + + [TestMethod] + public async Task WhenTimeoutAttributeOnMethodInNonTestClass_Diagnostic() + { + string code = """ + using Microsoft.VisualStudio.TestTools.UnitTesting; + + public class RegularClass + { + [[|Timeout(3000)|]] + public void SomeMethod() + { + } + } + """; + + string fixedCode = """ + using Microsoft.VisualStudio.TestTools.UnitTesting; + + public class RegularClass + { + [Timeout(3000, CooperativeCancellation = true)] + public void SomeMethod() + { + } + } + """; + + await VerifyCS.VerifyCodeFixAsync(code, fixedCode); + } + + [TestMethod] + public async Task WhenTimeoutAttributeWithNamedArgument_Diagnostic() + { + string code = """ + using Microsoft.VisualStudio.TestTools.UnitTesting; + + [TestClass] + public class MyTestClass + { + [TestMethod] + [[|Timeout(timeout: 5000)|]] + public void MyTestMethod() + { + } + } + """; + + string fixedCode = """ + using Microsoft.VisualStudio.TestTools.UnitTesting; + + [TestClass] + public class MyTestClass + { + [TestMethod] + [Timeout(timeout: 5000, CooperativeCancellation = true)] + public void MyTestMethod() + { + } + } + """; + + await VerifyCS.VerifyCodeFixAsync(code, fixedCode); + } } From 6862d89764866d52d69b0cb59c4c890f4bc81f07 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Amaury=20Lev=C3=A9?= Date: Sun, 5 Jul 2026 22:50:20 +0200 Subject: [PATCH 3/8] Re-enable LocalizationTests and LocalizationFailingTests (#9643) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> (cherry picked from commit 8c167ed99798d3747060ddb21eb75c2251013a11) --- .../LocalizationFailingTests.cs | 3 --- .../LocalizationTests.cs | 3 --- 2 files changed, 6 deletions(-) diff --git a/test/IntegrationTests/Microsoft.Testing.Platform.Acceptance.IntegrationTests/LocalizationFailingTests.cs b/test/IntegrationTests/Microsoft.Testing.Platform.Acceptance.IntegrationTests/LocalizationFailingTests.cs index a18df5479d..690bd3f91b 100644 --- a/test/IntegrationTests/Microsoft.Testing.Platform.Acceptance.IntegrationTests/LocalizationFailingTests.cs +++ b/test/IntegrationTests/Microsoft.Testing.Platform.Acceptance.IntegrationTests/LocalizationFailingTests.cs @@ -5,9 +5,6 @@ namespace Microsoft.Testing.Platform.Acceptance.IntegrationTests; -// Temporarily disabled: OneLocBuild keeps reverting the TerminalResources.*.xlf targets to English, -// which makes these tests fail on every loc check-in. Re-enable once proper translations flow. See https://github.com/microsoft/testfx/issues/9295. -[Ignore("Disabled until OneLocBuild ships proper TerminalResources translations. See https://github.com/microsoft/testfx/issues/9295.")] [TestClass] public sealed class LocalizationFailingTests : AcceptanceTestBase { diff --git a/test/IntegrationTests/Microsoft.Testing.Platform.Acceptance.IntegrationTests/LocalizationTests.cs b/test/IntegrationTests/Microsoft.Testing.Platform.Acceptance.IntegrationTests/LocalizationTests.cs index 3a1243b0c8..4e0fcd51c3 100644 --- a/test/IntegrationTests/Microsoft.Testing.Platform.Acceptance.IntegrationTests/LocalizationTests.cs +++ b/test/IntegrationTests/Microsoft.Testing.Platform.Acceptance.IntegrationTests/LocalizationTests.cs @@ -5,9 +5,6 @@ namespace Microsoft.Testing.Platform.Acceptance.IntegrationTests; -// Temporarily disabled: OneLocBuild keeps reverting the TerminalResources.*.xlf targets to English, -// which makes these tests fail on every loc check-in. Re-enable once proper translations flow. See https://github.com/microsoft/testfx/issues/9295. -[Ignore("Disabled until OneLocBuild ships proper TerminalResources translations. See https://github.com/microsoft/testfx/issues/9295.")] [TestClass] public class LocalizationTests : AcceptanceTestBase { From eade90ce549b44dded2694afb06c677b5ac6911f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Amaury=20Lev=C3=A9?= Date: Mon, 6 Jul 2026 14:54:24 +0200 Subject: [PATCH 4/8] perf: skip TCS bridge in ExecuteTestAsync when no ExecutionContext is captured (#9636) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> (cherry picked from commit 50adfdd022ed87272923243f0db7666607009c77) --- .../Execution/TestMethodRunner.cs | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/src/Adapter/MSTestAdapter.PlatformServices/Execution/TestMethodRunner.cs b/src/Adapter/MSTestAdapter.PlatformServices/Execution/TestMethodRunner.cs index a02f455014..48fa1ff2f9 100644 --- a/src/Adapter/MSTestAdapter.PlatformServices/Execution/TestMethodRunner.cs +++ b/src/Adapter/MSTestAdapter.PlatformServices/Execution/TestMethodRunner.cs @@ -496,12 +496,13 @@ private async Task ExecuteTestAsync(ITestContext executionContext, { try { - ExecutionContext? capturedContext = testMethodInfo.Parent.ExecutionContext ?? testMethodInfo.Parent.Parent.ExecutionContext; + ExecutionContext? capturedContext = testMethodInfo.Parent.ExecutionContext + ?? testMethodInfo.Parent.Parent.ExecutionContext; - // Fast path: when no ExecutionContext was captured by [AssemblyInitialize] / [ClassInitialize] - // (the common case), RunOnContext with a null context simply invokes the action directly on the - // current thread. Skip the TaskCompletionSource + closure + delegate allocations and just await - // the executor directly. + // Fast path: when no ExecutionContext was captured (the common case), + // ExecutionContextHelpers.RunOnContext would simply call the action inline. + // Skip the TaskCompletionSource bridge, async-lambda closure, and Action + // delegate allocations entirely. if (capturedContext is null) { using (TestContextImplementation.SetCurrentTestContext(executionContext as TestContext)) From a430dde88a23796c4e12a9eb70dba58dfe75f478 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Amaury=20Lev=C3=A9?= Date: Tue, 7 Jul 2026 06:01:23 +0200 Subject: [PATCH 5/8] docs: fix Changelog-Platform links and add #9641 entry (#9667) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> (cherry picked from commit af913e0db7c9cd8c9f33eb7d67e4b08cd1d868c3) --- docs/Changelog-Platform.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/Changelog-Platform.md b/docs/Changelog-Platform.md index 68cafac437..31abf180a1 100644 --- a/docs/Changelog-Platform.md +++ b/docs/Changelog-Platform.md @@ -44,6 +44,7 @@ See full log [of v4.2.3...v4.3.0](https://github.com/microsoft/testfx/compare/v4 * Forward Azure DevOps logging commands over the dotnet test pipe for multi-assembly test runs by @Evangelink in [#9463](https://github.com/microsoft/testfx/pull/9463) * Add `PropertyBag.FirstOrDefault()` for efficient single-property lookup without per-call array allocation by @Evangelink in [#9488](https://github.com/microsoft/testfx/pull/9488) * Add experimental `Microsoft.Testing.Extensions.GitHubActionsReport` extension emitting GitHub Actions workflow commands (per-assembly log groups, failure annotations, job summary, slow-test notices) by @azat-msft in [#9541](https://github.com/microsoft/testfx/pull/9541) +* Emit `::warning` annotations for skipped tests in `Microsoft.Testing.Extensions.GitHubActionsReport` by @Evangelink in [#9641](https://github.com/microsoft/testfx/pull/9641) ### Fixed @@ -1047,7 +1048,7 @@ See full log [of v1.3.2...v1.4.0](https://github.com/microsoft/testanywhere/comp ## [1.3.2] - 2024-08-05 -See full log [of v1.3.1...v1.3.2](https://github.com/microsoft/testanywhere/compare/v1.3.1...v1.3.2) +See full log [of v1.3.1...v1.3.2](https://github.com/microsoft/testfx/compare/v1.3.1...v1.3.2) ### Fixed @@ -1071,7 +1072,7 @@ See full log [of v1.3.1...v1.3.2](https://github.com/microsoft/testanywhere/comp ## [1.3.1] - 2024-07-15 -See full log [of v1.2.1...v1.3.1](https://github.com/microsoft/testanywhere/compare/v1.2.1...v1.3.1) +See full log [of v1.2.1...v1.3.1](https://github.com/microsoft/testfx/compare/v1.2.1...v1.3.1) ### Added From 866b1186d880f820ac762338b52bd6f953d40f50 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Amaury=20Lev=C3=A9?= Date: Tue, 7 Jul 2026 06:02:09 +0200 Subject: [PATCH 6/8] Add scheduled workflow to bump bundled Playwright (fixes #9362) (#9666) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> (cherry picked from commit b3c2614e8a31d40f992cb5c726e3b1e88b067edd) --- .github/scripts/update_bundled_playwright.py | 152 ++++++++++++++++++ .../workflows/update-bundled-playwright.yml | 91 +++++++++++ 2 files changed, 243 insertions(+) create mode 100644 .github/scripts/update_bundled_playwright.py create mode 100644 .github/workflows/update-bundled-playwright.yml diff --git a/.github/scripts/update_bundled_playwright.py b/.github/scripts/update_bundled_playwright.py new file mode 100644 index 0000000000..a012e13a23 --- /dev/null +++ b/.github/scripts/update_bundled_playwright.py @@ -0,0 +1,152 @@ +#!/usr/bin/env python3 +"""Bump the MSTest.Sdk-bundled Microsoft.Playwright.MSTest.v4 version. + +Dependabot cannot reliably open this bump on its own: it discovers the package +via the anchor in Directory.Packages.props and even computes the new version, +but drops the change at PR-assembly time for this specific package (see +https://github.com/microsoft/testfx/issues/9362). This script replaces that +one Dependabot flow. + +It looks up the latest STABLE Microsoft.Playwright.MSTest.v4 on nuget.org and, +when newer than what the repo bundles, rewrites BOTH coupled values in +Directory.Packages.props so they stay in sync (the _ValidateBundledSdkFeatureVersions +target fails the build otherwise): + + * the property (consumed by the build / baked into + the shipped SDK template), and + * the literal . + +Modes: + update Query nuget.org and rewrite Directory.Packages.props if a newer + stable version exists. Emits changed/old/new to $GITHUB_OUTPUT. + check Validate the two coupled values are present and in sync (used by the + workflow's pull_request self-test; no network, no writes). +""" +from __future__ import annotations + +import argparse +import json +import os +import re +import sys +import urllib.request +from pathlib import Path + +PACKAGE_ID = "Microsoft.Playwright.MSTest.v4" +FLAT_CONTAINER = ( + "https://api.nuget.org/v3-flatcontainer/" + "microsoft.playwright.mstest.v4/index.json" +) + +REPO_ROOT = Path(__file__).resolve().parents[2] +PROPS_PATH = REPO_ROOT / "Directory.Packages.props" + +PROPERTY_RE = re.compile( + r"()(?P[^<]+)()" +) +PACKAGE_VERSION_RE = re.compile( + r'([^\"]+)(\"\s*/>)" +) + + +def _stable_tuple(version: str) -> tuple[int, ...] | None: + """Return a comparable tuple for a stable version, or None for prereleases.""" + if "-" in version or "+" in version: + return None + parts = version.split(".") + if not all(p.isdigit() for p in parts): + return None + return tuple(int(p) for p in parts) + + +def latest_stable_version() -> str: + with urllib.request.urlopen(FLAT_CONTAINER, timeout=60) as response: + payload = json.load(response) + candidates = [] + for version in payload.get("versions", []): + key = _stable_tuple(version) + if key is not None: + candidates.append((key, version)) + if not candidates: + raise RuntimeError(f"No stable versions found for {PACKAGE_ID}.") + candidates.sort(key=lambda item: item[0]) + return candidates[-1][1] + + +def read_current_versions(text: str) -> tuple[str, str]: + prop_match = PROPERTY_RE.search(text) + pkg_match = PACKAGE_VERSION_RE.search(text) + if prop_match is None: + raise RuntimeError("Could not find in Directory.Packages.props.") + if pkg_match is None: + raise RuntimeError( + f'Could not find in Directory.Packages.props.' + ) + return prop_match.group("version"), pkg_match.group("version") + + +def set_github_output(**pairs: str) -> None: + output_path = os.environ.get("GITHUB_OUTPUT") + if not output_path: + return + with open(output_path, "a", encoding="utf-8") as handle: + for key, value in pairs.items(): + handle.write(f"{key}={value}\n") + + +def cmd_check() -> int: + text = PROPS_PATH.read_text(encoding="utf-8") + prop_version, pkg_version = read_current_versions(text) + if prop_version != pkg_version: + print( + "::error::MicrosoftPlaywrightVersion " + f"('{prop_version}') is out of sync with the {PACKAGE_ID} " + f"PackageVersion ('{pkg_version}')." + ) + return 1 + print(f"OK: bundled Playwright is {prop_version} (property and PackageVersion in sync).") + return 0 + + +def cmd_update() -> int: + text = PROPS_PATH.read_text(encoding="utf-8") + current_property, current_package = read_current_versions(text) + if current_property != current_package: + print( + "::error::Refusing to update: MicrosoftPlaywrightVersion " + f"('{current_property}') and the {PACKAGE_ID} PackageVersion " + f"('{current_package}') already disagree. Reconcile them first." + ) + return 1 + + current = current_property + latest = latest_stable_version() + print(f"Current bundled Playwright: {current}") + print(f"Latest stable on nuget.org: {latest}") + + if _stable_tuple(latest) <= _stable_tuple(current): + print("Already up to date; nothing to do.") + set_github_output(changed="false", old_version=current, new_version=current) + return 0 + + updated = PROPERTY_RE.sub(rf"\g<1>{latest}\g<3>", text, count=1) + updated = PACKAGE_VERSION_RE.sub(rf"\g<1>{latest}\g<3>", updated, count=1) + PROPS_PATH.write_text(updated, encoding="utf-8") + + print(f"Updated bundled Playwright {current} -> {latest}.") + set_github_output(changed="true", old_version=current, new_version=latest) + return 0 + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("mode", choices=["update", "check"]) + args = parser.parse_args() + if args.mode == "check": + return cmd_check() + return cmd_update() + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.github/workflows/update-bundled-playwright.yml b/.github/workflows/update-bundled-playwright.yml new file mode 100644 index 0000000000..d4e502b3e4 --- /dev/null +++ b/.github/workflows/update-bundled-playwright.yml @@ -0,0 +1,91 @@ +name: Update bundled Playwright + +# Keeps the MSTest.Sdk-bundled Microsoft.Playwright.MSTest.v4 version current. +# +# Dependabot cannot reliably do this: it discovers the package via the anchor in +# Directory.Packages.props and even computes the new version, but drops the change +# at PR-assembly time for this specific package. See +# https://github.com/microsoft/testfx/issues/9362 for the full investigation. +# +# This workflow replaces that one Dependabot flow: +# - Scheduled / manual runs look up the latest STABLE release on nuget.org and, +# when newer, rewrite BOTH coupled values in Directory.Packages.props +# (the property and the literal PackageVersion, +# which _ValidateBundledSdkFeatureVersions requires to stay in sync) and open +# a PR. +# - Pull-request runs that touch the script or workflow only self-test the +# script (no network writes, no PR) to catch regressions. + +on: + schedule: + # Weekly, Tuesday 07:00 UTC (offset from other scheduled workflow slots). + - cron: '0 7 * * 2' + workflow_dispatch: + pull_request: + paths: + - '.github/scripts/update_bundled_playwright.py' + - '.github/workflows/update-bundled-playwright.yml' + +permissions: + contents: read + +jobs: + self-test: + name: Self-test script + if: github.event_name == 'pull_request' + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + - name: Setup Python + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 + with: + python-version: '3.12' + - name: Validate Directory.Packages.props is in sync + run: python .github/scripts/update_bundled_playwright.py check + + update: + name: Bump bundled Playwright + if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' + runs-on: ubuntu-latest + # One in-flight update at a time so scheduled + manual runs don't race on the + # shared update-bundled-playwright/autoupdate branch. Scoped to this job so + # read-only PR self-tests are never gated behind the update lock. + concurrency: + group: update-bundled-playwright + cancel-in-progress: false + permissions: + contents: write + pull-requests: write + steps: + - name: Checkout + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + + - name: Setup Python + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 + with: + python-version: '3.12' + + - name: Compute and apply update + id: bump + run: python .github/scripts/update_bundled_playwright.py update + + - name: Create Pull Request + if: steps.bump.outputs.changed == 'true' + uses: peter-evans/create-pull-request@67ccf781d68cd99b580ae25a5c18a1cc84ffff1f # v7 + with: + commit-message: "Update bundled Playwright for .NET to ${{ steps.bump.outputs.new_version }}" + title: "Update bundled Playwright for .NET to ${{ steps.bump.outputs.new_version }}" + body: | + Bumps the MSTest.Sdk-bundled `Microsoft.Playwright.MSTest.v4` from + `${{ steps.bump.outputs.old_version }}` to `${{ steps.bump.outputs.new_version }}`. + + Updates both the `MicrosoftPlaywrightVersion` property and the matching + `Microsoft.Playwright.MSTest.v4` `PackageVersion` in `Directory.Packages.props` + so `_ValidateBundledSdkFeatureVersions` stays green. + + Opened by the `update-bundled-playwright` workflow because Dependabot + cannot reliably open this bump (see #9362). + branch: update-bundled-playwright/autoupdate + delete-branch: true + labels: dependencies From 94c922f891148d885fc6ccd817925bc033b59de5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Amaury=20Lev=C3=A9?= Date: Tue, 7 Jul 2026 06:46:50 +0200 Subject: [PATCH 7/8] test: add edge case tests for MSTEST0020/0021 analyzers (#9669) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> (cherry picked from commit 621b8ff3fe0e5fb075be83e485f9b99cb87a2959) --- ...structorOverTestInitializeAnalyzerTests.cs | 78 +++++++++++++++++++ ...eferDisposeOverTestCleanupAnalyzerTests.cs | 33 ++++++++ 2 files changed, 111 insertions(+) diff --git a/test/UnitTests/MSTest.Analyzers.UnitTests/PreferConstructorOverTestInitializeAnalyzerTests.cs b/test/UnitTests/MSTest.Analyzers.UnitTests/PreferConstructorOverTestInitializeAnalyzerTests.cs index d6a1f2d64e..5d2ee0bc71 100644 --- a/test/UnitTests/MSTest.Analyzers.UnitTests/PreferConstructorOverTestInitializeAnalyzerTests.cs +++ b/test/UnitTests/MSTest.Analyzers.UnitTests/PreferConstructorOverTestInitializeAnalyzerTests.cs @@ -404,6 +404,84 @@ private void SomePrivateMethod() await VerifyCS.VerifyCodeFixAsync(code, fixedCode); } + [TestMethod] + public async Task WhenTestInitializeMethodInNonTestClass_Diagnostic() + { + // The analyzer fires regardless of whether the containing class has [TestClass], + // and the fixer replaces the method with a constructor carrying its body. + string code = """ + using Microsoft.VisualStudio.TestTools.UnitTesting; + + public class MyClass + { + [TestInitialize] + public void [|MyInit|]() + { + int x = 1; + } + } + """; + string fixedCode = """ + using Microsoft.VisualStudio.TestTools.UnitTesting; + + public class MyClass + { + public MyClass() + { + int x = 1; + } + } + """; + + await VerifyCS.VerifyCodeFixAsync(code, fixedCode); + } + + [TestMethod] + public async Task WhenTestClassHasOnlyParameterizedCtorAndTestInitialize_CodeFix_MergesIntoParameterizedCtor() + { + // The fixer merges the TestInitialize body into the first non-static constructor found, + // even when that constructor has parameters and there is no default constructor. + string code = """ + using Microsoft.VisualStudio.TestTools.UnitTesting; + + [TestClass] + public class MyTestClass + { + private int _x; + private int _y; + + public MyTestClass(int y) + { + _y = y; + } + + [TestInitialize] + public void [|MyTestInit|]() + { + _x = 1; + } + } + """; + string fixedCode = """ + using Microsoft.VisualStudio.TestTools.UnitTesting; + + [TestClass] + public class MyTestClass + { + private int _x; + private int _y; + + public MyTestClass(int y) + { + _y = y; + _x = 1; + } + } + """; + + await VerifyCS.VerifyCodeFixAsync(code, fixedCode); + } + [TestMethod] public async Task WhenTestClassHasTestInitializeWithMultiLineBody_PreservesIndentation() { diff --git a/test/UnitTests/MSTest.Analyzers.UnitTests/PreferDisposeOverTestCleanupAnalyzerTests.cs b/test/UnitTests/MSTest.Analyzers.UnitTests/PreferDisposeOverTestCleanupAnalyzerTests.cs index 1627e30caf..02ebee0752 100644 --- a/test/UnitTests/MSTest.Analyzers.UnitTests/PreferDisposeOverTestCleanupAnalyzerTests.cs +++ b/test/UnitTests/MSTest.Analyzers.UnitTests/PreferDisposeOverTestCleanupAnalyzerTests.cs @@ -381,6 +381,39 @@ public class MyTestClass } #endif + [TestMethod] + public async Task WhenTestCleanupMethodInNonTestClass_Diagnostic() + { + // The analyzer fires regardless of whether the containing class has [TestClass], + // and the fixer adds IDisposable to the base list and replaces the method with Dispose(). + string code = """ + using Microsoft.VisualStudio.TestTools.UnitTesting; + + public class MyClass + { + [TestCleanup] + public void [|MyCleanup|]() + { + int x = 1; + } + } + """; + string fixedCode = """ + using System; + using Microsoft.VisualStudio.TestTools.UnitTesting; + + public class MyClass : IDisposable + { + public void Dispose() + { + int x = 1; + } + } + """; + + await VerifyCS.VerifyCodeFixAsync(code, fixedCode); + } + [TestMethod] public async Task WhenTestClassHasTestCleanupWithMultiLineBody_PreservesIndentation() { From 40030259c8ee3e0f7602e0316a6695c07e6b039b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Amaury=20Lev=C3=A9?= Date: Tue, 7 Jul 2026 06:48:48 +0200 Subject: [PATCH 8/8] perf: avoid GetParameters() allocation in test-invocation hot path (#9671) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> (cherry picked from commit 2e7c2216fd6560991b868e92f3d4f9a0680adda5) --- .../Execution/TestMethodInfo.Execution.cs | 4 ++-- .../Extensions/MethodInfoExtensions.cs | 24 +++++++++++-------- 2 files changed, 16 insertions(+), 12 deletions(-) diff --git a/src/Adapter/MSTestAdapter.PlatformServices/Execution/TestMethodInfo.Execution.cs b/src/Adapter/MSTestAdapter.PlatformServices/Execution/TestMethodInfo.Execution.cs index 6515ea57fa..26530800e4 100644 --- a/src/Adapter/MSTestAdapter.PlatformServices/Execution/TestMethodInfo.Execution.cs +++ b/src/Adapter/MSTestAdapter.PlatformServices/Execution/TestMethodInfo.Execution.cs @@ -144,7 +144,7 @@ private async Task ExecuteInternalAsync(object?[]? arguments, Cancel { if (_executionContext is null) { - Task? invokeResult = MethodInfo.GetInvokeResultAsync(_classInstance, arguments); + Task? invokeResult = MethodInfo.GetInvokeResultWithParametersAsync(_classInstance, ParameterTypes, arguments); if (invokeResult is not null) { await invokeResult.ConfigureAwait(true); @@ -161,7 +161,7 @@ private async Task ExecuteInternalAsync(object?[]? arguments, Cancel #if NETFRAMEWORK CallContext.HostContext = _hostContext; #endif - Task? invokeResult = MethodInfo.GetInvokeResultAsync(_classInstance, arguments); + Task? invokeResult = MethodInfo.GetInvokeResultWithParametersAsync(_classInstance, ParameterTypes, arguments); if (invokeResult is not null) { await invokeResult.ConfigureAwait(false); diff --git a/src/Adapter/MSTestAdapter.PlatformServices/Extensions/MethodInfoExtensions.cs b/src/Adapter/MSTestAdapter.PlatformServices/Extensions/MethodInfoExtensions.cs index 2349db7f15..6456ba31bf 100644 --- a/src/Adapter/MSTestAdapter.PlatformServices/Extensions/MethodInfoExtensions.cs +++ b/src/Adapter/MSTestAdapter.PlatformServices/Extensions/MethodInfoExtensions.cs @@ -117,12 +117,17 @@ private static bool IsValueTask(this MethodInfo method) } internal static Task? GetInvokeResultAsync(this MethodInfo methodInfo, object? classInstance, params object?[]? arguments) - { - ParameterInfo[]? methodParameters = methodInfo.GetParameters(); + // MethodInfo.GetParameters() allocates a fresh ParameterInfo[] on every call (CLR safety + // guarantee). Non-hot-path callers go through this thin wrapper; the hot path (data-driven + // test invocation) calls GetInvokeResultWithParametersAsync directly with an already-cached + // array to avoid the per-invocation allocation. + => methodInfo.GetInvokeResultWithParametersAsync(classInstance, methodInfo.GetParameters(), arguments); + internal static Task? GetInvokeResultWithParametersAsync(this MethodInfo methodInfo, object? classInstance, ParameterInfo[] methodParameters, params object?[]? arguments) + { // check if test method expected parameter values but no test data was provided, // throw error with appropriate message. - if (methodParameters is { Length: > 0 } && arguments == null) + if (methodParameters is { Length: > 0 } && arguments is null) { throw new TestFailedException( UnitTestOutcome.Error, @@ -150,7 +155,7 @@ private static bool IsValueTask(this MethodInfo method) // IndexOutOfRangeException. Mirror the reflection path's friendly diagnostic instead. We // only validate the count here — a type mismatch surfaces as the test's own exception and // must not be reinterpreted as an arguments error. - int expectedParameterCount = methodParameters?.Length ?? 0; + int expectedParameterCount = methodParameters.Length; int providedArgumentCount = sourceGeneratedArguments?.Length ?? 0; if (expectedParameterCount != providedArgumentCount) { @@ -162,7 +167,7 @@ private static bool IsValueTask(this MethodInfo method) methodInfo.DeclaringType!.FullName, methodInfo.Name, expectedParameterCount, - string.Join(", ", methodParameters?.Select(p => p.ParameterType.Name) ?? []), + string.Join(", ", methodParameters.Select(p => p.ParameterType.Name)), providedArgumentCount, string.Join(", ", sourceGeneratedArguments?.Select(a => a?.GetType().Name ?? "null") ?? []))); } @@ -184,7 +189,7 @@ private static bool IsValueTask(this MethodInfo method) } else { - int methodParametersLengthOrZero = methodParameters?.Length ?? 0; + int methodParametersLengthOrZero = methodParameters.Length; int argumentsLengthOrZero = arguments?.Length ?? 0; #if WINDOWS_UWP @@ -203,7 +208,7 @@ private static bool IsValueTask(this MethodInfo method) { if (methodInfo.IsGenericMethod) { - methodInfo = ConstructGenericMethod(methodInfo, arguments); + methodInfo = ConstructGenericMethod(methodInfo, methodParameters, arguments); } invokeResult = methodInfo.Invoke(classInstance, arguments); @@ -218,7 +223,7 @@ private static bool IsValueTask(this MethodInfo method) methodInfo.DeclaringType!.FullName, methodInfo.Name, methodParametersLengthOrZero, - string.Join(", ", methodParameters?.Select(p => p.ParameterType.Name) ?? []), + string.Join(", ", methodParameters.Select(p => p.ParameterType.Name)), argumentsLengthOrZero, string.Join(", ", arguments?.Select(a => a?.GetType().Name ?? "null") ?? [])), ex); } @@ -285,7 +290,7 @@ private static void InferGenerics(Type parameterType, Type argumentType, List<(T // public void TestMethod(T2 p0, T1, p1) { } [UnconditionalSuppressMessage("ReflectionAnalysis", "IL2060:Call to 'System.Reflection.MethodInfo.MakeGenericMethod' can not be statically analyzed.", Justification = "Generic test methods with substituted type arguments are part of MSTest's reflection-mode adapter. Native AOT support relies on MSTest source-generated metadata, not on this code path.")] [UnconditionalSuppressMessage("Aot", "IL3050:Avoid calling members annotated with 'RequiresDynamicCodeAttribute' when publishing as Native AOT", Justification = "Generic test methods with substituted type arguments are part of MSTest's reflection-mode adapter. Native AOT support relies on MSTest source-generated metadata, not on this code path.")] - private static MethodInfo ConstructGenericMethod(MethodInfo methodInfo, object?[]? arguments) + private static MethodInfo ConstructGenericMethod(MethodInfo methodInfo, ParameterInfo[] parameters, object?[]? arguments) { DebugEx.Assert(methodInfo.IsGenericMethod, "ConstructGenericMethod should only be called for a generic method."); @@ -304,7 +309,6 @@ private static MethodInfo ConstructGenericMethod(MethodInfo methodInfo, object?[ map[i] = (genericDefinitions[i], null); } - ParameterInfo[] parameters = methodInfo.GetParameters(); for (int i = 0; i < parameters.Length; i++) { Type parameterType = parameters[i].ParameterType;