Skip to content
Merged
152 changes: 152 additions & 0 deletions .github/scripts/update_bundled_playwright.py
Original file line number Diff line number Diff line change
@@ -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 <MicrosoftPlaywrightVersion> property (consumed by the build / baked into
the shipped SDK template), and
* the literal <PackageVersion Include="Microsoft.Playwright.MSTest.v4" ...>.

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"(<MicrosoftPlaywrightVersion>)(?P<version>[^<]+)(</MicrosoftPlaywrightVersion>)"
)
PACKAGE_VERSION_RE = re.compile(
r'(<PackageVersion\s+Include="Microsoft\.Playwright\.MSTest\.v4"\s+Version=")'
r"(?P<version>[^\"]+)(\"\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 <MicrosoftPlaywrightVersion> in Directory.Packages.props.")
if pkg_match is None:
raise RuntimeError(
f'Could not find <PackageVersion Include="{PACKAGE_ID}" .../> 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):
Comment thread
Evangelink marked this conversation as resolved.
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())
20 changes: 14 additions & 6 deletions .github/workflows/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
91 changes: 91 additions & 0 deletions .github/workflows/update-bundled-playwright.yml
Original file line number Diff line number Diff line change
@@ -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 <MicrosoftPlaywrightVersion> 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
5 changes: 3 additions & 2 deletions docs/Changelog-Platform.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<TProperty>()` 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

Expand Down Expand Up @@ -1047,7 +1048,7 @@ See full log [of v1.3.2...v1.4.0](https://github.com/microsoft/testanywhere/comp

## <a name="1.3.2" />[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

Expand All @@ -1071,7 +1072,7 @@ See full log [of v1.3.1...v1.3.2](https://github.com/microsoft/testanywhere/comp

## <a name="1.3.1" />[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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -144,7 +144,7 @@ private async Task<TestResult> 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);
Expand All @@ -161,7 +161,7 @@ private async Task<TestResult> 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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -496,12 +496,13 @@ private async Task<TestResult[]> 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))
Expand Down
Loading
Loading