diff --git a/.azure-pipelines/publish-docker.yml b/.azure-pipelines/publish-docker.yml index 0590ef961..3e05d3b06 100644 --- a/.azure-pipelines/publish-docker.yml +++ b/.azure-pipelines/publish-docker.yml @@ -68,8 +68,8 @@ extends: - task: NuGetAuthenticate@1 displayName: "Authenticate NuGet to DevDiv_PublicPackages feed" - # Playwright.Tooling fetches playwright-core with `npm pack`, which picks up - # the registry and credentials from this .npmrc. + # Playwright.Tooling builds the driver from .upstream/ with `npm ci`, which + # picks up the registry and credentials from this .npmrc. - task: UseNode@1 displayName: "Install Node.js" inputs: @@ -97,8 +97,8 @@ extends: echo '{ "data-root": "/mnt/docker" }' | sudo tee /etc/docker/daemon.json sudo service docker start - - bash: ./build.sh --download-driver - displayName: "Download the driver" + - bash: ./build.sh --build-driver + displayName: "Build the driver" - task: AzureCLI@2 displayName: "Login to ACR via OIDC" diff --git a/.azure-pipelines/publish.yml b/.azure-pipelines/publish.yml index 7af284ad1..5a498b13b 100644 --- a/.azure-pipelines/publish.yml +++ b/.azure-pipelines/publish.yml @@ -91,8 +91,8 @@ extends: displayName: 'Use DevDiv_PublicPackages as the only NuGet source' - task: NuGetAuthenticate@1 displayName: 'Authenticate NuGet to DevDiv_PublicPackages feed' - # Playwright.Tooling fetches playwright-core with `npm pack`, which picks up - # the registry and credentials from this .npmrc. + # Playwright.Tooling builds the driver from .upstream/ with `npm ci`, which + # picks up the registry and credentials from this .npmrc. - task: UseNode@1 displayName: 'Install Node.js' inputs: @@ -105,12 +105,13 @@ extends: displayName: 'Authenticate npm to DevDiv_PublicPackages feed' inputs: workingFile: .npmrc - # We need to download the driver first, so we can build + # We need to build the driver first, so we can build. This clones + # microsoft/playwright into .upstream/ at DriverVersion of src/Common/Version.props. - task: DotNetCoreCLI@2 - displayName: Download the driver + displayName: Build the driver inputs: command: 'run' - arguments: '--project $(Build.SourcesDirectory)/src/tools/Playwright.Tooling/Playwright.Tooling.csproj -- download-drivers --basepath $(Build.SourcesDirectory)' + arguments: '--project $(Build.SourcesDirectory)/src/tools/Playwright.Tooling/Playwright.Tooling.csproj -- build-driver --basepath $(Build.SourcesDirectory)' - task: DotNetCoreCLI@2 displayName: Build Playwright NuGet Packages inputs: diff --git a/.claude/skills/playwright-dotnet-release/SKILL.md b/.claude/skills/playwright-dotnet-release/SKILL.md index 866fa417c..37390c5c4 100644 --- a/.claude/skills/playwright-dotnet-release/SKILL.md +++ b/.claude/skills/playwright-dotnet-release/SKILL.md @@ -7,6 +7,18 @@ Use this skill once the roll PR has merged into `main` and the upstream JS `v1.X Throughout this doc, replace `X` with the minor version (e.g. `64` for `1.64.0`) and `` with the fork owner (`gh api user --jq .login`). +Check that the driver is rolled. `DriverVersion` in `src/Common/Version.props` on `main` must be a commit of the upstream `release-1.X` branch: the tag `v1.X.0`, or the tip of the branch, as the user says. The driver reports the version of `playwright-core` at that commit, so it must be `1.X.0`: + +```bash +./build.sh --build-driver +pin=$(git -C .upstream rev-parse HEAD) +git -C .upstream fetch origin release-1.X +git -C .upstream merge-base --is-ancestor "$pin" FETCH_HEAD && echo "on release-1.X" +git -C .upstream show "$pin":packages/playwright-core/package.json | grep '"version"' +``` + +If it is not, roll first (`./build.sh --roll v1.X.0`, or `--roll release-1.X`). + The full release checklist lives in the private `microsoft/playwright-internal` repo as the `v1.X release checklist` issue. Find its number once: ```bash @@ -51,7 +63,7 @@ git show upstream/release-1.X:src/Common/Version.props | grep AssemblyVersion ## 3. Release notes -The .NET notes in `docs/src/release-notes-csharp.md` are written upstream together with the Node.js notes, following the `playwright-release-notes` skill (`../playwright/.claude/skills/playwright-release-notes/SKILL.md`). Assume the `## Version 1.X` section is already on `release-1.X`, and only add what's missing: significant changes that only the .NET port has, such as a new NUnit, MSTest or xUnit integration feature or a .NET-only API. Leave out minor bug fixes. To find candidates, list this repo's changes since the previous release branch was cut: +The .NET notes in `docs/src/release-notes-csharp.md` are written upstream together with the Node.js notes, following the `playwright-release-notes` skill (`.claude/skills/playwright-release-notes/SKILL.md` on `main` of `microsoft/playwright`). Assume the `## Version 1.X` section is already on `release-1.X`, and only add what's missing: significant changes that only the .NET port has, such as a new NUnit, MSTest or xUnit integration feature or a .NET-only API. Leave out minor bug fixes. To find candidates, list this repo's changes since the previous release branch was cut: ```bash git log --oneline --no-merges $(git merge-base upstream/release-1. upstream/main)..upstream/main | grep -E '^\w+ (feat|fix)' diff --git a/.claude/skills/playwright-roll/SKILL.md b/.claude/skills/playwright-roll/SKILL.md index 361876036..28142b3f5 100644 --- a/.claude/skills/playwright-roll/SKILL.md +++ b/.claude/skills/playwright-roll/SKILL.md @@ -7,58 +7,57 @@ Help the user roll to a new version of Playwright. ../../../ROLLING.md contains general instructions and scripts. See also [CLAUDE.md](../../../CLAUDE.md) for build, test, and architecture details. -Unless the exact version is specified by the user, you need to find the latest version of the driver. -Check the publish workflow here: https://github.com/microsoft/playwright/actions/workflows/publish_release.yml. The step that builds and publishes the driver contains the exact version you need. +`DriverVersion` in `src/Common/Version.props` holds one `microsoft/playwright` commit. `.upstream/` (gitignored) is a full clone of `microsoft/playwright` at that commit. The driver is built from it, the API and the transport channels are generated from it, and the hand-written client ports its JS client. A roll moves `DriverVersion` from the old commit to the new one, and ports the diff between the two. Do not commit, branch or edit in `.upstream/`. -Now, with the driver version known, always start with running the roll script to update the version and regenerate the API to see the state of things. +Roll to the ref that the user names: a branch (`main`, `release-1.66`), a tag (`v1.66.0`) or a commit. Without one, roll to `main`. + +Always start with running the roll script, to see the state of things. It writes the commit to `DriverVersion`, checks it out in `.upstream/`, builds the driver from it and regenerates the API. It needs git and Node.js with npm. ```bash -./build.sh --roll +./build.sh --roll +old=$(git show HEAD:src/Common/Version.props | sed -n 's|.*\(.*\).*|\1|p') +new=$(git -C .upstream rev-parse HEAD) ``` -Afterwards, walk through the upstream changes that affect the Java client and port the relevant ones. +The driver reports the version of `packages/playwright-core/package.json` at the new commit: `1.66.0` on a release tag, `1.67.0-next` on `main`. The script prints it. + +Afterwards, walk through the upstream changes that affect the .NET client and port the relevant ones. ## Determining what to port -List the upstream commits that touched a client-relevant path since the last roll. The paths cover everything that can change the public Java surface or the wire protocol: +List the upstream commits between `$old` and `$new` that touched a client-relevant path. The paths cover everything that can change the public .NET surface or the wire protocol: - `docs/src/api/` — the source of truth for `api.json`. Method/option additions, removals, and `langs:` filter changes flow from here. -- `packages/playwright-core/src/client/` — the JS client implementation that the Java client mirrors. +- `packages/playwright-core/src/client/` — the JS client implementation that the .NET client mirrors. - `packages/isomorphic/` — selector engines, locator generation/parsing, and aria-snapshot logic shared between client and server. Changes here can affect client-side helpers like `getByRoleSelector`. - `packages/playwright/src/matchers/matchers.ts` — assertion-method definitions. Changes here usually correspond to new options on `LocatorAssertions` / `PageAssertions`. - `packages/protocol/spec/**.yml` — the wire protocol schema. Method/event additions, parameter renames, and result-shape changes affect what the .NET implementation classes need to send/receive. ```bash -cd ~/playwright -PREV_TAG=$(git tag | grep -E '^v1\.[0-9]+\.[0-9]+$' | sort -V | tail -1) # e.g. v1.59.1 -git log "$PREV_TAG"..HEAD --oneline -- \ - 'docs/src/api/' \ - 'packages/playwright-core/src/client/' \ - 'packages/isomorphic/' \ - 'packages/playwright/src/matchers/matchers.ts' \ - 'packages/protocol/spec/**.yml' +./build.sh --changes # since DriverVersion at HEAD, oldest first +./build.sh --changes # or since another commit or tag, e.g. v1.65.0 ``` -Walk that list top-to-bottom (oldest-first is easier — newest is at top, so reverse). For each commit: -1. Read the commit (`git show `) to see what client/protocol/docs changed. +Walk that list top-to-bottom. For each commit: +1. Read the commit (`git -C .upstream show `) to see what client/protocol/docs changed. 2. If it's JS-internal (bundling, dispatcher conventions, electron, mcp, dashboard, trace-viewer, test-runner) — skip. 3. If it touches `docs/src/api/` or types, check `langs:` annotations — features marked with languages other than "csharp" do not apply. 4. If it adds/changes a public API method or option that applies to .NET, port it. The 'build.sh --roll' already regenerated the types/options, so we usually only need to update the implementation. -5. Watch for follow-up reverts — a "feat: X" commit might be undone by a later "Revert X". Check whether the change still exists in HEAD before porting. +5. Watch for follow-up reverts — a "feat: X" commit might be undone by a later "Revert X". Check whether the change still exists in `.upstream/` before porting. 6. Maintain a running notes file (e.g. `/tmp/roll-notes.md`) listing each upstream PR as ported / skipped / verified-already-supported, with a one-line reason. This file becomes the body of the eventual PR. 7. Do not forget to port tests that cover .NET-relevant changes. At least one test per every API method/option to excercise the new code paths. Some server-side implementation tests are not needed in this port. ## Mimicking the JavaScript implementation -The .NET client is a port of the JS client in `../playwright/packages/playwright-core/src/client/`. When implementing a new or changed method, always read the corresponding JS file first and mirror its logic: +The .NET client is a port of the JS client in `.upstream/packages/playwright-core/src/client/`. When implementing a new or changed method, always read the corresponding JS file first and mirror its logic: ``` -../playwright/packages/playwright-core/src/client/browserContext.ts -../playwright/packages/playwright-core/src/client/page.ts +.upstream/packages/playwright-core/src/client/browserContext.ts +.upstream/packages/playwright-core/src/client/page.ts ... ``` -**Read the final state at the release tag, not just the introducing PR diff.** When a feature evolves across multiple PRs (e.g. a method was introduced in one PR, then some options were added in follow-ups, then some logic was changed), the original PR's diff is a misleading reference — the final shape lives in `git show v1.X.0:packages/playwright-core/src/client/foo.ts`. Always read the v1.X.0 file before porting non-trivial logic. +**Read the final state in `.upstream/`, not just the introducing PR diff.** When a feature evolves across multiple PRs (e.g. a method was introduced in one PR, then some options were added in follow-ups, then some logic was changed), the original PR's diff is a misleading reference — the final shape lives in `.upstream/packages/playwright-core/src/client/foo.ts`, which is at the new commit. Always read that file before porting non-trivial logic. `git -C .upstream diff "$old" "$new" -- ` shows everything that changed in one file. Key translation rules: @@ -68,7 +67,7 @@ Key translation rules: **Watch for channel migrations.** A method that lived on `BrowserContext` may move to `Tracing` (and vice-versa) without notice — the protocol spec is the source of truth. If a `SendMessageToServerAsync("foo", ...)` call suddenly fails, grep `packages/protocol/spec/*.yml` to find which channel actually owns `foo` now. -**Wire format changes can be subtle.** A method's result shape can change without renaming anything. These are easy to miss when walking client.ts because the JS code just adapts; the regression only surfaces when an existing test's deserialization breaks. Always diff `packages/protocol/spec/*.yml` for each commit on the walk list, not just the client code. +**Wire format changes can be subtle.** A method's result shape can change without renaming anything. These are easy to miss when walking client.ts because the JS code just adapts; the regression only surfaces when an existing test's deserialization breaks. Always diff `packages/protocol/spec/*.yml` for each commit on the walk list, not just the client code. `git -C .upstream diff "$old" "$new" -- packages/protocol/spec` shows the whole change of the protocol. **Update tests similarly to upstream.** When upstream removes or modifies a test, apply similar changes. @@ -84,7 +83,9 @@ In the docs markdown file (e.g. `docs/src/api/class-browser.md`), add `alias` (a + - alias-csharp: BrowserBindResult ``` -Use `alias-csharp` (in addition to bare `alias`) when upstream already declared a different `alias` for other languages and you only want to override .NET's name — typically to preserve a name we've already shipped and don't want to break. After making this change, re-run `./build.sh --roll ` to regenerate, then update any hand-written implementation code to use the new type name. The upstream docs change must also land in `microsoft/playwright` via a separate PR or it will revert on the next roll. +Use `alias-csharp` (in addition to bare `alias`) when upstream already declared a different `alias` for other languages and you only want to override .NET's name — typically to preserve a name we've already shipped and don't want to break. + +The generated files come from `.upstream/` at `DriverVersion`, and CI fails when they differ, so the docs change must land in `microsoft/playwright` first. Make it in another checkout of `microsoft/playwright` (not in `.upstream/`), and try it with `PW_SRC_DIR= ./build.sh --generate`. Once the upstream PR has merged, roll to a commit that has it, then update any hand-written implementation code to use the new type name. ## Running the full test suite @@ -100,6 +101,9 @@ tail -5 /tmp/test-results.txt # summary Run as a background task — don't block the conversation on it. For each failure: check whether it's a flake (port collision, browser timing) by running the single test in isolation, then investigate whether your roll introduced it or it's pre-existing. ## Tips & Tricks -- Project checkouts are in the parent directory (`../`). +- `.upstream/` is the upstream checkout, at the new commit. Only read from it. - Use the "gh" cli to interact with GitHub. -- When running git commands against an upstream repo, always use `git -C /path/to/repo ` instead of `cd /path/to/repo && git `. +- When running git commands against the upstream repo, always use `git -C .upstream ` instead of `cd .upstream && git `. +- `.upstream/ is not at `: run `./build.sh --build-driver`. It checks out `DriverVersion` and builds the driver again when the commit changed. +- `.upstream has local changes`: see them with `git -C .upstream status`, and discard them. +- `./build.sh --generate` regenerates the API and the transport channels without moving `DriverVersion`. diff --git a/.claude/skills/playwright-triage/SKILL.md b/.claude/skills/playwright-triage/SKILL.md index fa719ce2c..d8127347c 100644 --- a/.claude/skills/playwright-triage/SKILL.md +++ b/.claude/skills/playwright-triage/SKILL.md @@ -16,7 +16,7 @@ outcome that this repo adds, an upstream bug report. ## The checkouts `$REPO_DIR` is the checkout of `microsoft/playwright-dotnet` that the guide describes. The driver is -downloaded (`./build.sh --download-driver`), the solution is built (`dotnet build ./src`), and +built (`./build.sh --build-driver`), the solution is built (`dotnet build ./src`), and Chromium, Firefox and WebKit are installed (`pwsh src/Playwright/bin/Debug/netstandard2.0/playwright.ps1 install --with-deps`). It is the working directory, and the checkout that the guides refer to. The clone is shallow: run @@ -26,12 +26,16 @@ working directory, and the checkout that the guides refer to. The clone is shall `$PLAYWRIGHT_DIR` is a shallow clone of `microsoft/playwright` at `main`. The .NET client is a port of the JavaScript client in `packages/playwright-core/src/client/`. The API docs are in `docs/src/api/`, the API generator is `utils/doclint/generateDotnetApi.js`, and the upstream tests -are in `tests/page/` and `tests/library/`. The clone is not at the commit of the driver: -`` in `src/Common/Version.props` names that version, `main` is close to it, and a -release has the tag `v`. The clone is not built. Before you run an upstream test in -it, run `npm ci`, `npm run build` and `npx playwright install` there. Then `npm run ctest `, +are in `tests/page/` and `tests/library/`. The clone is not at the commit of the driver, but `main` +is close to it. The clone is not built. Before you run an upstream test in it, run `npm ci`, +`npm run build` and `npx playwright install` there. Then `npm run ctest `, `npm run ftest ` and `npm run wtest ` run one spec file on Chromium, Firefox and WebKit. +`$REPO_DIR/.upstream` is a full clone of `microsoft/playwright` at the commit of the driver, which +`` in `src/Common/Version.props` names. The driver is built from it, and the API is +generated from it. Read the code of the driver there, and use its history and tags. Do not commit, +branch or edit in it: write and run upstream tests in `$PLAYWRIGHT_DIR`. + ## Published versions To run a published version, for example to bisect, create @@ -46,7 +50,7 @@ dotnet run Put the repro in `Program.cs` as top-level statements, as `src/Playwright.Examples/Program.cs` does. `playwright.ps1 install` fetches the browsers of that version. Match the change to commits in `$REPO_DIR` with `git log v..v -- src/Playwright/`, and to the JavaScript client in -`$PLAYWRIGHT_DIR` with the same tags. +`$REPO_DIR/.upstream` with the same tags. ## Upstream bugs @@ -94,7 +98,7 @@ Write the rest of the comment as the note: the verdict, the evidence, and that t ## PRs from third parties Do not run a PR that changes a `.csproj`, `.props` or `.sln` file, `nuget.config`, `build.sh`, -`src/tools/Playwright.Tooling/` (the driver download), `src/Playwright/build/` (MSBuild targets that +`src/tools/Playwright.Tooling/` (the driver build), `src/Playwright/build/` (MSBuild targets that run on every build), `.github/`, `.azure-pipelines/`, `utils/`, `package.json`, `package-lock.json`, or the generated files under `src/Playwright/API/Generated/` and `src/Playwright/Transport/Protocol/Generated/`. diff --git a/.github/actions/setup-triage-runner/action.yml b/.github/actions/setup-triage-runner/action.yml index 14f20e3f9..e636966e1 100644 --- a/.github/actions/setup-triage-runner/action.yml +++ b/.github/actions/setup-triage-runner/action.yml @@ -17,10 +17,10 @@ runs: - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 with: node-version: '24' - - name: Download the driver and build + - name: Build the driver and the solution shell: bash run: | - ./build.sh --download-driver + ./build.sh --build-driver dotnet build ./src - name: Install the browsers shell: bash diff --git a/.github/workflows/code-style.yml b/.github/workflows/code-style.yml index 1f8c9233a..0fda38945 100644 --- a/.github/workflows/code-style.yml +++ b/.github/workflows/code-style.yml @@ -19,9 +19,16 @@ jobs: dotnet-version: | 8.0.x 10.0.x + - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 + with: + node-version: '24' - name: Check formatting run: dotnet format ./src/ --verify-no-changes -v:diag - - name: Download driver - run: ./build.sh --download-driver + - name: Build the driver + run: ./build.sh --build-driver + - name: Check that the generated files are up to date + run: | + ./build.sh --generate + git diff --exit-code - name: Build projects (runs .NET analyzers) run: dotnet build ./src diff --git a/.github/workflows/nuget-package-tests.yml b/.github/workflows/nuget-package-tests.yml index ca9cc7230..95910f813 100644 --- a/.github/workflows/nuget-package-tests.yml +++ b/.github/workflows/nuget-package-tests.yml @@ -22,9 +22,12 @@ jobs: uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 with: dotnet-version: 8.0.x - - name: Install prerequisites and download drivers + - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 + with: + node-version: '24' + - name: Build the driver shell: bash - run: ./build.sh --download-driver + run: ./build.sh --build-driver # .NET internally has two sources when we install a tool with --add-source # so our local version needs to be higher that it gets priority over the remote one. # Also it should not include any pre-release versions (include next). diff --git a/.github/workflows/test_docker.yml b/.github/workflows/test_docker.yml index 60ef13190..a7399fdad 100644 --- a/.github/workflows/test_docker.yml +++ b/.github/workflows/test_docker.yml @@ -34,9 +34,12 @@ jobs: dotnet-version: | 8.0.x 10.0.x - - name: Install prerequisites and download drivers + - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 + with: + node-version: '24' + - name: Build the driver shell: bash - run: ./build.sh --download-driver + run: ./build.sh --build-driver - name: Build Docker image run: | ARCH="${{ matrix.runs-on == 'ubuntu-24.04-arm' && 'arm64' || 'amd64' }}" diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 587b37bc2..4be9df44d 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -32,9 +32,12 @@ jobs: dotnet-version: | 8.0.x 10.0.x - - name: Install prerequisites and download drivers + - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 + with: + node-version: '24' + - name: Build the driver shell: bash - run: ./build.sh --download-driver + run: ./build.sh --build-driver - name: Building run: dotnet build ./src - name: Installing Browsers and dependencies diff --git a/.github/workflows/tests_harness.yml b/.github/workflows/tests_harness.yml index 592d0eec4..cb5e204f8 100644 --- a/.github/workflows/tests_harness.yml +++ b/.github/workflows/tests_harness.yml @@ -26,9 +26,9 @@ jobs: - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 with: node-version: 22 - - name: Install prerequisites and download drivers + - name: Build the driver shell: bash - run: ./build.sh --download-driver + run: ./build.sh --build-driver - name: Building run: dotnet build ./src - name: Installing Browsers and dependencies diff --git a/.github/workflows/validate-nuget-packages.yml b/.github/workflows/validate-nuget-packages.yml index b165e8117..4e4ffbf13 100644 --- a/.github/workflows/validate-nuget-packages.yml +++ b/.github/workflows/validate-nuget-packages.yml @@ -19,7 +19,10 @@ jobs: dotnet-version: | 8.0.x 10.0.x - - name: Download driver - run: ./build.sh --download-driver + - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 + with: + node-version: '24' + - name: Build the driver + run: ./build.sh --build-driver - name: Validate NuGet packages run: dotnet pack ./src/Playwright.sln diff --git a/.gitignore b/.gitignore index 7b9d6d001..112611f8a 100644 --- a/.gitignore +++ b/.gitignore @@ -352,6 +352,9 @@ MigrationBackup/ .ionide/ .idea src/Playwright.sln.DotSettings +# The microsoft/playwright checkout at DriverVersion of src/Common/Version.props +# (./build.sh --build-driver). +/.upstream/ src/Playwright/.drivers src/Playwright/Microsoft.Playwright.xml diff --git a/CLAUDE.md b/CLAUDE.md index 2021c9acd..b2e2b32e6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -3,10 +3,12 @@ ## Building ```bash -./build.sh --download-driver # download the Playwright driver (needs Node.js/npm on PATH) +./build.sh --build-driver # build the Playwright driver from .upstream/ (needs git and Node.js/npm on PATH) dotnet build ./src # build the entire solution ``` +`DriverVersion` in `src/Common/Version.props` is the `microsoft/playwright` commit that the driver, the generated API and the port come from. `.upstream/` (gitignored) is a clone of `microsoft/playwright` at that commit: `./build.sh --build-driver` clones it, checks out the commit and builds the driver from it. Run it again when `DriverVersion` changes, for example after a pull. Read upstream code in `.upstream/`; do not commit, branch or edit in it. + ## Running tests Follow `.github/workflows/tests.yml` for the canonical sequence. @@ -45,7 +47,8 @@ The tests are NUnit tests in `src/Playwright.Tests/`, one class per upstream spe ## Architecture ### Generated vs hand-written code -- Public API interfaces (e.g. `src/Playwright/API/Generated/IPage.cs`) are **generated** by `../playwright/utils/doclint/generateDotnetApi.js` from the upstream API docs. Do not hand-edit these — update the generator instead. +- Public API interfaces (e.g. `src/Playwright/API/Generated/IPage.cs`) are **generated** by `.upstream/utils/doclint/generateDotnetApi.js` from the upstream API docs. Do not hand-edit these — update the generator instead. +- `./build.sh --generate` generates them again from `.upstream/`, and CI fails when the committed files differ. The generator change lands upstream first, and a roll brings it here. To try it before that, `PW_SRC_DIR=../playwright ./build.sh --generate` runs the generators of another checkout. - The generator uses `classNameMap` for type mappings (e.g. `Disposable` → `IAsyncDisposable`, `boolean` → `bool`). Add entries there when a Playwright type should map to a different .NET type. - The generator skips generating interface files for types like `TimeoutException` and `IAsyncDisposable` that map to built-in .NET types. - Supplement interfaces (`src/Playwright/API/Supplements/`) are hand-written and extend the generated interfaces with .NET-specific overloads. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index f6a19189d..b831d8e9e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -77,16 +77,18 @@ There are lots of thoughts and [approaches](https://github.com/antlr/antlr4-cpp/ ### Prerequisites -Before building the solution for the first time, you will need to download the drivers. You can do this by either running commands manually, or by using the provided script. +You need the .NET SDK, git, and Node.js 20 or newer with npm. #### Initialize -When you get the repo, you need to download the drivers, dotnet format tool, etc. To do this, you can call: +Before building the solution, build the driver: ```bash -./build.sh --download-driver +./build.sh --build-driver ``` +The driver comes from [microsoft/playwright](https://github.com/microsoft/playwright), at the commit in `DriverVersion` of `src/Common/Version.props`. The script clones that repository into `.upstream/` (gitignored), checks out the commit and builds the driver from it into `src/Playwright/.drivers`. Run it again when `DriverVersion` changes, for example after a pull. Do not commit, branch or edit in `.upstream/`. + #### Dotnet Format To help with formatting, you can make use of `dotnet format`. All you have to do is run @@ -122,15 +124,19 @@ Additionally, you can use the Test Explorer if you're using Visual Studio. ### Generating the APIs & rolling the driver (upstream) -We use [this](https://github.com/microsoft/playwright/blob/master/utils/doclint/generateDotnetApi.js) generator for the API and [that](https://github.com/microsoft/playwright/blob/master/utils/generate_dotnet_channels.js) for generating the transport channels. Both are located upstream. +We use [this](https://github.com/microsoft/playwright/blob/main/utils/doclint/generateDotnetApi.js) generator for the API and [that](https://github.com/microsoft/playwright/blob/main/utils/generate_dotnet_channels.js) for generating the transport channels. Both are located upstream, and run from `.upstream/`. The generated files are committed, and CI fails when they differ from what `.upstream/` generates: + +```bash +./build.sh --generate +``` -To generate the API, identify the upstream driver version from the GitHub Actions logs of the point in time you wish to roll to, and run: +To move to a new upstream commit, pass a `microsoft/playwright` branch, tag or commit: ```bash -./build.sh --roll +./build.sh --roll ``` -This will re-generate the necessary files for the new driver version. +This will set `DriverVersion`, build the driver and re-generate the necessary files. See [ROLLING.md](./ROLLING.md). ### Collecting coverage when running tests diff --git a/README.md b/README.md index 2f9680ee0..829b37c18 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ | | Linux | macOS | Windows | | :--- | :---: | :---: | :---: | -| Chromium 158.0.8127.2 | ✅ | ✅ | ✅ | +| Chromium 158.0.8127.18 | ✅ | ✅ | ✅ | | WebKit 27.2 | ✅ | ✅ | ✅ | | Firefox 159.0 | ✅ | ✅ | ✅ | diff --git a/ROLLING.md b/ROLLING.md index 249227e75..b2a209011 100644 --- a/ROLLING.md +++ b/ROLLING.md @@ -1,18 +1,31 @@ # Rolling Playwright-dotnet +`DriverVersion` in `src/Common/Version.props` holds the `microsoft/playwright` commit that the driver, the generated API and the ported code come from. `.upstream/` (gitignored) is a clone of `microsoft/playwright` at that commit: `./build.sh --build-driver` clones it, checks out the commit and builds the driver from it. A roll moves `DriverVersion` to a new commit and ports what changed in between. The full process, for people and agents, is in [.claude/skills/playwright-roll/SKILL.md](./.claude/skills/playwright-roll/SKILL.md). + ## Pre-requisites -Install .NET SDK 8 with installer: https://dotnet.microsoft.com/download/dotnet/8.0 +- .NET SDK 8: https://dotnet.microsoft.com/download/dotnet/8.0 +- git, and Node.js 20 or newer with npm ## Rolling -1. Checkout the upstream main/release branch of the `playwright` repository. -1. Checkout the main/release branch of the `playwright-dotnet` repository. -3. Pick the latest driver from GitHub Action where you want to roll to. For releases, it should be `1.X.Y`. For main branch, it should be something like `1.X.Y-`. -4. By default it will pick the `playwright` project from `../playwright`. You can override it by setting the `PW_SRC_DIR` environment variable. +1. **Roll** to a branch (`main`, `release-1.66`), a tag (`v1.66.0`) or a commit of `microsoft/playwright`. + + ```bash + ./build.sh --roll + ``` + + This writes the commit and the latest Node.js LTS version to `src/Common/Version.props`, checks the commit out in `.upstream/`, builds the driver from it, generates the API and the transport channels again, and updates the browser versions in the README. The driver reports the version of `packages/playwright-core/package.json` at that commit: `1.66.0` on a release tag, `1.67.0-next` on `main`. + +2. **Port** what changed upstream into `src/Playwright/` and `src/Playwright.Tests/`. + + ```bash + ./build.sh --changes # the upstream commits to port, since DriverVersion at HEAD + ``` -```bash -./build.sh --roll -``` +3. **Check**, then commit and open the PR. -This will complete the entire process (download and set the new driver, re-generate API and transport channels, update the README). + ```bash + dotnet build ./src + BROWSER=chromium dotnet test ./src/Playwright.Tests/Playwright.Tests.csproj -c Debug -f net8.0 + ``` diff --git a/build.sh b/build.sh index e8fe70a85..5e220043a 100755 --- a/build.sh +++ b/build.sh @@ -8,8 +8,12 @@ cd "$(dirname "$0")" if [[ ($1 == '--help') || ($1 == '-h') ]]; then echo "usage: build.sh " echo "commands:" - echo " --roll - roll the .NET language binding to a specific driver version" - echo " --download-driver - download the driver" + echo " --build-driver - clone microsoft/playwright into .upstream/ at the commit in" + echo " src/Common/Version.props (DriverVersion), and build the driver from it" + echo " --generate - generate the API and the transport channels from .upstream/" + echo " --roll - roll to a microsoft/playwright branch, tag or commit: set DriverVersion," + echo " build the driver and generate" + echo " --changes [] - list the upstream commits to port since (default: DriverVersion at HEAD)" echo " --help - show this help" echo exit 0 @@ -21,61 +25,142 @@ if [[ $# == 0 ]]; then exit 1 fi -upstream_repo_path="../playwright" -if [[ -n "$PW_SRC_DIR" ]]; then - upstream_repo_path="$PW_SRC_DIR" -fi -echo "Upstream repo path: ${upstream_repo_path}" +upstream_url="https://github.com/microsoft/playwright.git" +# The microsoft/playwright clone at DriverVersion (gitignored). --build-driver manages it. +upstream_repo_path=".upstream" +version_props_path="./src/Common/Version.props" + +# The upstream paths that can change the .NET client: the API docs, the JS client that +# the .NET client ports, the code that the JS client shares with the server, the +# assertions and the wire protocol. +client_paths=( + 'docs/src/api/' + 'packages/playwright-core/src/client/' + 'packages/isomorphic/' + 'packages/playwright/src/matchers/matchers.ts' + 'packages/protocol/spec/' +) + +function read_driver_version() { + sed -n 's|.*\(.*\).*|\1|p' +} -function download_driver() { - echo "downloading driver..." - dotnet run --project ./src/tools/Playwright.Tooling/Playwright.Tooling.csproj -- download-drivers --basepath . +function build_driver() { + echo "building driver..." + dotnet run --project ./src/tools/Playwright.Tooling/Playwright.Tooling.csproj -- build-driver --basepath . echo "done" } -function roll_driver() { - new_driver_version="$1" - upstream_package_version=$(node -e "console.log(require('${upstream_repo_path}/package.json').version)") - new_node_version=$(curl -fsSL "https://nodejs.org/dist/index.json" \ - | node -e "let s='';process.stdin.on('data',d=>s+=d).on('end',()=>console.log(JSON.parse(s).find(r=>r.lts).version.slice(1)))") - if [[ -z "${new_node_version}" ]]; then - echo "Failed to determine the Node.js version for driver ${new_driver_version}" +function require_upstream() { + local driver_version=$(read_driver_version < "${version_props_path}") + if [[ "$(git -C "${upstream_repo_path}" rev-parse HEAD 2>/dev/null)" != "${driver_version}" ]]; then + echo "${upstream_repo_path}/ is not at ${driver_version}, the commit in ${version_props_path}. Run: ./build.sh --build-driver" exit 1 fi - echo "Rolling .NET driver to driver ${new_driver_version} (Node.js ${new_node_version}) and upstream version ${upstream_package_version}..." - - xml_file_path="./src/Common/Version.props" - xml_file_contents=$(cat "${xml_file_path}") - xml_file_contents=$(echo "${xml_file_contents}" | sed "s|.*|${new_driver_version}|") - xml_file_contents=$(echo "${xml_file_contents}" | sed "s|.*|${new_node_version}|") - echo "${xml_file_contents}" > "${xml_file_path}" +} +function generate() { + # The generators are upstream. PW_SRC_DIR runs them from another microsoft/playwright + # checkout (with `npm ci` done), to try a change to a generator or to the docs. The + # committed files must come from .upstream/: CI generates them again and fails when + # they differ. + local generator_repo_path="${upstream_repo_path}" + if [[ -n "$PW_SRC_DIR" ]]; then + generator_repo_path="$PW_SRC_DIR" + echo "Generating from ${generator_repo_path}, not from ${upstream_repo_path}/" + else + require_upstream + fi echo "Generating API..." rm -rf src/Playwright/API/Generated/ - node "$upstream_repo_path/utils/doclint/generateDotnetApi.js" "src/Playwright" + node "$generator_repo_path/utils/doclint/generateDotnetApi.js" "src/Playwright" echo "Generating transport channels..." rm -rf "src/Playwright/Transport/Protocol/Generated/" - node "$upstream_repo_path/utils/generate_dotnet_channels.js" "src/Playwright" + node "$generator_repo_path/utils/generate_dotnet_channels.js" "src/Playwright" echo "Formatting source code..." dotnet format src/Playwright + echo "done" +} + +# Prints the commit of a microsoft/playwright branch, tag or commit. +function resolve_ref() { + local ref="$1" + if [[ "${ref}" =~ ^[0-9a-f]{40}$ ]]; then + echo "${ref}" + return + fi + # "^{}" is the commit that an annotated tag points to. + local names=("refs/tags/${ref}^{}" "refs/tags/${ref}" "refs/heads/${ref}") + local refs=$(git ls-remote "${upstream_url}" "${names[@]}") + local name + for name in "${names[@]}"; do + local commit=$(echo "${refs}" | awk -v name="${name}" '$2 == name { print $1 }') + if [[ -n "${commit}" ]]; then + echo "${commit}" + return + fi + done + echo "${ref} is not a branch, a tag or a full commit of ${upstream_url}" >&2 + return 1 +} - download_driver +function roll() { + if [[ -z "$1" ]]; then + echo "missing : a microsoft/playwright branch (main, release-1.66), tag (v1.66.0) or commit" + exit 1 + fi + local old_driver_version=$(read_driver_version < "${version_props_path}") + local new_driver_version + new_driver_version=$(resolve_ref "$1") + local new_node_version=$(curl -fsSL "https://nodejs.org/dist/index.json" \ + | node -e "let s='';process.stdin.on('data',d=>s+=d).on('end',()=>console.log(JSON.parse(s).find(r=>r.lts).version.slice(1)))") + if [[ -z "${new_node_version}" ]]; then + echo "Failed to determine the Node.js version for the driver" + exit 1 + fi + echo "Rolling from microsoft/playwright ${old_driver_version} to ${new_driver_version} (Node.js ${new_node_version})..." + + local xml_file_contents=$(cat "${version_props_path}") + xml_file_contents=$(echo "${xml_file_contents}" | sed "s|.*|${new_driver_version}|") + xml_file_contents=$(echo "${xml_file_contents}" | sed "s|.*|${new_node_version}|") + echo "${xml_file_contents}" > "${version_props_path}" - pushd src/Playwright.TestingHarnessTest/ - echo "Updating driver version in tests..." - npm install --save-exact "@playwright/test@${new_driver_version}" - popd + # The channels generator needs the dependencies that the driver build installs. + build_driver + generate - echo "done" + local upstream_package_version=$(node -e "console.log(require('./${upstream_repo_path}/packages/playwright-core/package.json').version)") + echo "Rolled to microsoft/playwright ${new_driver_version}; the driver reports version ${upstream_package_version}" +} + +function changes() { + require_upstream + local new_driver_version=$(read_driver_version < "${version_props_path}") + local old_driver_version="$1" + if [[ -z "${old_driver_version}" ]]; then + old_driver_version=$(git show "HEAD:${version_props_path}" | read_driver_version) + fi + if [[ "${old_driver_version}" == "${new_driver_version}" ]]; then + echo "DriverVersion is ${new_driver_version} at HEAD too; pass the commit to compare with." + exit 1 + fi + # A commit that no branch or tag reaches any more, for example on a deleted branch. + git -C "${upstream_repo_path}" cat-file -e "${old_driver_version}^{commit}" 2>/dev/null \ + || git -C "${upstream_repo_path}" fetch --quiet origin "${old_driver_version}" + git -C "${upstream_repo_path}" --no-pager log --oneline --reverse "${old_driver_version}..${new_driver_version}" -- "${client_paths[@]}" } CMD="$1" if [[ ("$CMD" == "--roll") ]]; then - roll_driver $2 -elif [[ ("$CMD" == "--download-driver") ]]; then - download_driver + roll "$2" +elif [[ ("$CMD" == "--build-driver") ]]; then + build_driver +elif [[ ("$CMD" == "--generate") ]]; then + generate +elif [[ ("$CMD" == "--changes") ]]; then + changes "$2" else echo "ERROR: unknown command - $CMD" echo "Pass --help for supported commands" exit 1 -fi \ No newline at end of file +fi diff --git a/src/Common/Version.props b/src/Common/Version.props index dc440dc8b..c0e260f92 100644 --- a/src/Common/Version.props +++ b/src/Common/Version.props @@ -2,7 +2,7 @@ 1.65.0 $(AssemblyVersion)-beta1 - 1.65.0-alpha-1793732293000 + 5ccfbd85521679ebf5ab4d836852bf1c544aa748 26.13.0 diff --git a/src/Playwright.TestingHarnessTest/package-lock.json b/src/Playwright.TestingHarnessTest/package-lock.json index b199a80b5..562f4bf8f 100644 --- a/src/Playwright.TestingHarnessTest/package-lock.json +++ b/src/Playwright.TestingHarnessTest/package-lock.json @@ -7,19 +7,19 @@ "": { "name": "playwright.testingharnesstest", "devDependencies": { - "@playwright/test": "1.65.0-alpha-1793732293000", + "@playwright/test": "1.65.0", "@types/node": "^22.12.0", "fast-xml-parser": "^4.5.0" } }, "node_modules/@playwright/test": { - "version": "1.65.0-alpha-1793732293000", - "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.65.0-alpha-1793732293000.tgz", - "integrity": "sha512-mTKVktzW9r990D1hQREp62V/5yHoNUg2H+hYXwuEhzk0ieQGyewZr8wMTVJVTpfE4Z19KC/CcuwGeLEmxH/UwA==", + "version": "1.65.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.65.0.tgz", + "integrity": "sha512-iGURLGuKDLakSEl7Pgfv8h7f+XLT+V861Wfhz0zlEl5nyFuKUopdkFBPKqTllTUp06aOwPuH8iuUEb3yp3MpbQ==", "dev": true, "license": "Apache-2.0", "dependencies": { - "playwright": "1.65.0-alpha-1793732293000" + "playwright": "1.65.0" }, "bin": { "playwright": "cli.js" @@ -62,13 +62,13 @@ } }, "node_modules/playwright": { - "version": "1.65.0-alpha-1793732293000", - "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.65.0-alpha-1793732293000.tgz", - "integrity": "sha512-1ecOmIfgsH/kXpcK7pEhd8Cvt0WKjt8ZtGebfvRrHn6ekYh+BF+STkRRf+oJSa1Awg5TPk9pggd7F0klfMH11g==", + "version": "1.65.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.65.0.tgz", + "integrity": "sha512-F3AMWcR44qMBt4kq+6Kb6mX7bjGXshktKFOPULd7reED6ZJWevjbNRqysP7q+AATE9kdPEZBibex1BYsvpVoeA==", "dev": true, "license": "Apache-2.0", "dependencies": { - "playwright-core": "1.65.0-alpha-1793732293000" + "playwright-core": "1.65.0" }, "bin": { "playwright": "cli.js" @@ -78,9 +78,9 @@ } }, "node_modules/playwright-core": { - "version": "1.65.0-alpha-1793732293000", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.65.0-alpha-1793732293000.tgz", - "integrity": "sha512-sCR/P2UZpCwLBwgqafU6ygOU06BgPM75fUlrkp98176XUFbJyq2pt57yHbaRlmhovRYzR9Bs/ZKL6wYS5XulZA==", + "version": "1.65.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.65.0.tgz", + "integrity": "sha512-vNSKgH6YqtN7cdT/L334zo0a+YwkrpicyrHJlWWPIm/Q+Nn2SWInil01oT0mbu96U4GgoRtCYQD9CzJQgju+lA==", "dev": true, "license": "Apache-2.0", "bin": { @@ -106,12 +106,12 @@ }, "dependencies": { "@playwright/test": { - "version": "1.65.0-alpha-1793732293000", - "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.65.0-alpha-1793732293000.tgz", - "integrity": "sha512-mTKVktzW9r990D1hQREp62V/5yHoNUg2H+hYXwuEhzk0ieQGyewZr8wMTVJVTpfE4Z19KC/CcuwGeLEmxH/UwA==", + "version": "1.65.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.65.0.tgz", + "integrity": "sha512-iGURLGuKDLakSEl7Pgfv8h7f+XLT+V861Wfhz0zlEl5nyFuKUopdkFBPKqTllTUp06aOwPuH8iuUEb3yp3MpbQ==", "dev": true, "requires": { - "playwright": "1.65.0-alpha-1793732293000" + "playwright": "1.65.0" } }, "@types/node": { @@ -133,18 +133,18 @@ } }, "playwright": { - "version": "1.65.0-alpha-1793732293000", - "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.65.0-alpha-1793732293000.tgz", - "integrity": "sha512-1ecOmIfgsH/kXpcK7pEhd8Cvt0WKjt8ZtGebfvRrHn6ekYh+BF+STkRRf+oJSa1Awg5TPk9pggd7F0klfMH11g==", + "version": "1.65.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.65.0.tgz", + "integrity": "sha512-F3AMWcR44qMBt4kq+6Kb6mX7bjGXshktKFOPULd7reED6ZJWevjbNRqysP7q+AATE9kdPEZBibex1BYsvpVoeA==", "dev": true, "requires": { - "playwright-core": "1.65.0-alpha-1793732293000" + "playwright-core": "1.65.0" } }, "playwright-core": { - "version": "1.65.0-alpha-1793732293000", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.65.0-alpha-1793732293000.tgz", - "integrity": "sha512-sCR/P2UZpCwLBwgqafU6ygOU06BgPM75fUlrkp98176XUFbJyq2pt57yHbaRlmhovRYzR9Bs/ZKL6wYS5XulZA==", + "version": "1.65.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.65.0.tgz", + "integrity": "sha512-vNSKgH6YqtN7cdT/L334zo0a+YwkrpicyrHJlWWPIm/Q+Nn2SWInil01oT0mbu96U4GgoRtCYQD9CzJQgju+lA==", "dev": true }, "strnum": { diff --git a/src/Playwright.TestingHarnessTest/package.json b/src/Playwright.TestingHarnessTest/package.json index cfc1ce2e7..4c77e4b87 100644 --- a/src/Playwright.TestingHarnessTest/package.json +++ b/src/Playwright.TestingHarnessTest/package.json @@ -2,7 +2,7 @@ "name": "playwright.testingharnesstest", "private": true, "devDependencies": { - "@playwright/test": "1.65.0-alpha-1793732293000", + "@playwright/test": "1.65.0", "@types/node": "^22.12.0", "fast-xml-parser": "^4.5.0" } diff --git a/src/Playwright.TestingHarnessTest/tests/baseTest.ts b/src/Playwright.TestingHarnessTest/tests/baseTest.ts index 30727841f..89b0aec6a 100644 --- a/src/Playwright.TestingHarnessTest/tests/baseTest.ts +++ b/src/Playwright.TestingHarnessTest/tests/baseTest.ts @@ -37,10 +37,14 @@ export const test = base.extend<{ await server.stop(); }, testMode: null, - launchServer: async ({ playwright }, use) => { + launchServer: async ({}, use) => { + // The server is the driver that the .NET client is built with, so that the two + // have the same protocol and the same browsers. @playwright/test only runs + // these tests: its version does not follow the driver. + const driver: typeof import('@playwright/test') = require(path.join(__dirname, '..', '..', 'Playwright', '.drivers', 'package')); const servers: BrowserServer[] = []; await use(async ({port}: {port: number}) => { - servers.push(await playwright.chromium.launchServer({ host: '127.0.0.1', port })); + servers.push(await driver.chromium.launchServer({ host: '127.0.0.1', port })); }); for (const server of servers) await server.close(); diff --git a/src/Playwright/API/Generated/IBrowserType.cs b/src/Playwright/API/Generated/IBrowserType.cs index 0863a1c19..7ccc5f7fd 100644 --- a/src/Playwright/API/Generated/IBrowserType.cs +++ b/src/Playwright/API/Generated/IBrowserType.cs @@ -125,6 +125,11 @@ public partial interface IBrowserType /// /// A CDP websocket endpoint or http url to connect to. For example http://localhost:9222/ /// or ws://127.0.0.1:9222/devtools/browser/387adf4c-243f-4051-a181-46798f4a46f4. + /// Alternatively, a browser channel name such as chrome or msedge to + /// connect to a browser with remote debugging enabled in its default user data directory. + /// On Linux, Playwright uses CHROME_USER_DATA_DIR when set. Otherwise, it uses + /// CHROME_CONFIG_HOME, then XDG_CONFIG_HOME, then ~/.config to + /// locate that directory. /// /// Call options Task ConnectOverCDPAsync(string endpointURL, BrowserTypeConnectOverCDPOptions? options = default); diff --git a/src/Playwright/Playwright.csproj b/src/Playwright/Playwright.csproj index 6db060edb..a46be919e 100644 --- a/src/Playwright/Playwright.csproj +++ b/src/Playwright/Playwright.csproj @@ -44,7 +44,7 @@ - @@ -54,6 +54,6 @@ - + diff --git a/src/tools/Playwright.Tooling/DriverDownloader.cs b/src/tools/Playwright.Tooling/DriverBuilder.cs similarity index 58% rename from src/tools/Playwright.Tooling/DriverDownloader.cs rename to src/tools/Playwright.Tooling/DriverBuilder.cs index a711f0ed7..722f4df4e 100644 --- a/src/tools/Playwright.Tooling/DriverDownloader.cs +++ b/src/tools/Playwright.Tooling/DriverBuilder.cs @@ -28,6 +28,7 @@ using System.Formats.Tar; using System.IO; using System.IO.Compression; +using System.Linq; using System.Net.Http; using System.Text.RegularExpressions; using System.Threading.Tasks; @@ -37,17 +38,30 @@ namespace Playwright.Tooling; /// -/// Assembles the Playwright driver into src/Playwright/.drivers from the -/// playwright-core npm package and the official Node.js builds, mirroring what -/// upstream's utils/build/build-playwright-driver.sh bundles into the prebuilt -/// driver archives. The resulting directory layout matches the .playwright -/// folder shipped in the NuGet package: +/// Builds the Playwright driver into src/Playwright/.drivers. The driver is the +/// playwright-core package of microsoft/playwright at the commit in DriverVersion +/// (src/Common/Version.props), plus the official Node.js binaries of +/// DriverNodeVersion. This: +/// 1. clones microsoft/playwright into .upstream/ (gitignored) the first time, +/// and checks out the commit; +/// 2. builds it with `npm ci` and `npm run build` (once per commit); +/// 3. packs packages/playwright-core with `npm pack`, and adds the Node.js +/// binary of each platform. +/// The resulting directory layout matches the .playwright folder shipped in the +/// NuGet package: /// .drivers/package/** - playwright-core npm package contents /// .drivers/node/LICENSE - Node.js license /// .drivers/node/<platform>/node[.exe] - per-platform Node.js binary. +/// A driver that is built is not built again until DriverVersion or +/// DriverNodeVersion changes. Do not commit, branch or edit in .upstream/: the +/// driver is built only from a checkout with no local changes. /// -internal class DriverDownloader +internal class DriverBuilder { + private const string UpstreamUrl = "https://github.com/microsoft/playwright.git"; + + private static readonly Regex _commitRegex = new("^[0-9a-f]{40}$", RegexOptions.Compiled); + // Directory names under .drivers/node/ are the platform ids that Driver.cs // resolves at runtime; the suffixes name the Node.js download for that platform. private static readonly (string PlatformId, string NodeSuffix)[] _platforms = new[] @@ -61,20 +75,28 @@ private static readonly (string PlatformId, string NodeSuffix)[] _platforms = ne public string BasePath { get; set; } + /// The microsoft/playwright commit that the driver is built from. public string DriverVersion { get; set; } public string NodeVersion { get; set; } - internal static Task RunAsync(DownloadDriversOptions o) + private string UpstreamPath => Path.Combine(BasePath, ".upstream"); + + internal static Task RunAsync(BuildDriverOptions o) { + string propsPath = Path.Combine(o.BasePath, "src", "Common", "Version.props"); var props = new XmlDocument(); - props.Load(Path.Combine(o.BasePath, "src", "Common", "Version.props")); - string driverVersion = props.DocumentElement.SelectSingleNode("/Project/PropertyGroup/DriverVersion").FirstChild.Value; - string nodeVersion = props.DocumentElement.SelectSingleNode("/Project/PropertyGroup/DriverNodeVersion").FirstChild.Value; + props.Load(propsPath); + string driverVersion = props.DocumentElement.SelectSingleNode("/Project/PropertyGroup/DriverVersion").FirstChild.Value.Trim(); + string nodeVersion = props.DocumentElement.SelectSingleNode("/Project/PropertyGroup/DriverNodeVersion").FirstChild.Value.Trim(); + if (!_commitRegex.IsMatch(driverVersion)) + { + throw new Exception($"DriverVersion in {propsPath} must be a microsoft/playwright commit (40 hex digits), got \"{driverVersion}\""); + } - return new DriverDownloader() + return new DriverBuilder() { - BasePath = o.BasePath, + BasePath = Path.GetFullPath(o.BasePath), DriverVersion = driverVersion, NodeVersion = nodeVersion, }.ExecuteAsync(); @@ -162,15 +184,88 @@ private static string ResolveNpmExecutable() return name; } + private static async Task<(int ExitCode, string Output)> RunProcessAsync(string fileName, string workingDirectory, IEnumerable arguments, bool captureOutput = false, IDictionary environment = null) + { + var startInfo = new ProcessStartInfo(fileName) + { + WorkingDirectory = workingDirectory, + UseShellExecute = false, + RedirectStandardOutput = captureOutput, + }; + foreach (string argument in arguments) + { + startInfo.ArgumentList.Add(argument); + } + foreach (var (name, value) in environment ?? new Dictionary()) + { + startInfo.Environment[name] = value; + } + Process process; + try + { + process = Process.Start(startInfo) ?? throw new Exception($"Failed to start {fileName}"); + } + catch (System.ComponentModel.Win32Exception e) + { + throw new Exception($"{fileName} was not found on PATH; git, Node.js and npm are required to build the driver.", e); + } + using (process) + { + string output = captureOutput ? await process.StandardOutput.ReadToEndAsync().ConfigureAwait(false) : string.Empty; + await process.WaitForExitAsync().ConfigureAwait(false); + return (process.ExitCode, output.Trim()); + } + } + + private async Task GitAsync(params string[] arguments) + { + var (exitCode, output) = await RunProcessAsync("git", UpstreamPath, arguments, captureOutput: true).ConfigureAwait(false); + if (exitCode != 0) + { + throw new Exception($"git {string.Join(' ', arguments)} failed with exit code {exitCode}"); + } + return output; + } + + private async Task HasCommitAsync(string commit) + { + var (exitCode, _) = await RunProcessAsync("git", UpstreamPath, new[] { "rev-parse", "--verify", "--quiet", commit + "^{commit}" }, captureOutput: true).ConfigureAwait(false); + return exitCode == 0; + } + + private async Task HasLocalChangesAsync() + => (await GitAsync("status", "--porcelain").ConfigureAwait(false)).Length > 0; + + private async Task RunNpmAsync(string workingDirectory, params string[] arguments) + { + var environment = new Dictionary(); + string npmrc = Path.Combine(BasePath, ".npmrc"); + if (File.Exists(npmrc)) + { + // npm does not run in the repository root, so name the root-level .npmrc: + // the release pipeline writes the registry and its credentials there, to + // point npm at the internal Azure Artifacts feed. + environment["npm_config_userconfig"] = npmrc; + } + Console.WriteLine($"Running npm {string.Join(' ', arguments)} in {workingDirectory}"); + var (exitCode, _) = await RunProcessAsync(ResolveNpmExecutable(), workingDirectory, arguments, environment: environment).ConfigureAwait(false); + if (exitCode != 0) + { + throw new Exception($"npm {string.Join(' ', arguments)} failed with exit code {exitCode}"); + } + } + private async Task ExecuteAsync() { + await CheckoutUpstreamAsync().ConfigureAwait(false); + var driversDirectory = new DirectoryInfo(Path.Combine(BasePath, "src", "Playwright", ".drivers")); string stamp = $"driver {DriverVersion} node {NodeVersion}"; string stampFile = Path.Combine(driversDirectory.FullName, ".stamp"); if (File.Exists(stampFile) && File.ReadAllText(stampFile) == stamp) { - Console.WriteLine("Drivers are up-to-date"); + Console.WriteLine("The driver is up-to-date"); } else { @@ -187,7 +282,7 @@ private async Task ExecuteAsync() var tasks = new List { - DownloadPlaywrightPackageAsync(driversDirectory.FullName), + BuildPlaywrightPackageAsync(driversDirectory.FullName), }; foreach (var (platformId, nodeSuffix) in _platforms) { @@ -199,45 +294,80 @@ private async Task ExecuteAsync() } // update readme - await UpdateBrowserVersionsAsync(BasePath, DriverVersion).ConfigureAwait(false); + await UpdateBrowserVersionsAsync().ConfigureAwait(false); return true; } - private async Task DownloadPlaywrightPackageAsync(string driversDirectory) + /// + /// Checks out DriverVersion in .upstream/, after cloning microsoft/playwright there the first time. + /// + private async Task CheckoutUpstreamAsync() { - // Fetched with `npm pack` rather than a hard-coded registry URL so that the - // registry configured for npm (and its credentials) are used, e.g. the - // root-level .npmrc the release pipeline writes to point at the internal - // Azure Artifacts feed. npm is run from the repository root for that reason. - string package = $"playwright-core@{DriverVersion}"; - string tempDirectory = Directory.CreateTempSubdirectory("playwright-core-").FullName; - try + bool cloned = false; + if (!Directory.Exists(Path.Combine(UpstreamPath, ".git"))) { - Console.WriteLine($"Downloading {package} with npm pack"); - var startInfo = new ProcessStartInfo(ResolveNpmExecutable()) + Console.WriteLine($"Cloning {UpstreamUrl} into {UpstreamPath}"); + var (exitCode, _) = await RunProcessAsync("git", BasePath, new[] { "clone", "--no-checkout", UpstreamUrl, UpstreamPath }).ConfigureAwait(false); + if (exitCode != 0) { - WorkingDirectory = Path.GetFullPath(BasePath), - UseShellExecute = false, - }; - startInfo.ArgumentList.Add("pack"); - startInfo.ArgumentList.Add(package); - startInfo.ArgumentList.Add("--pack-destination"); - startInfo.ArgumentList.Add(tempDirectory); - using (var process = Process.Start(startInfo) ?? throw new Exception("Failed to start npm")) + throw new Exception($"git clone {UpstreamUrl} failed with exit code {exitCode}"); + } + cloned = true; + } + if (cloned || await GitAsync("rev-parse", "HEAD").ConfigureAwait(false) != DriverVersion) + { + if (!cloned && await HasLocalChangesAsync().ConfigureAwait(false)) { - await process.WaitForExitAsync().ConfigureAwait(false); - if (process.ExitCode != 0) - { - throw new Exception($"npm pack {package} failed with exit code {process.ExitCode}"); - } + throw new Exception($"{UpstreamPath} has local changes. Discard them before checking out {DriverVersion}."); + } + if (!await HasCommitAsync(DriverVersion).ConfigureAwait(false)) + { + await GitAsync("fetch", "--quiet", "--tags", "origin").ConfigureAwait(false); + } + if (!await HasCommitAsync(DriverVersion).ConfigureAwait(false)) + { + // A commit that no branch or tag reaches any more, for example on a deleted branch. + await GitAsync("fetch", "--quiet", "origin", DriverVersion).ConfigureAwait(false); } + await GitAsync("checkout", "--quiet", "--detach", DriverVersion).ConfigureAwait(false); + } + Console.WriteLine($"{UpstreamPath} is at {DriverVersion}"); + } + + /// + /// Builds .upstream/ and extracts its playwright-core package into .drivers/package. + /// + private async Task BuildPlaywrightPackageAsync(string driversDirectory) + { + if (await HasLocalChangesAsync().ConfigureAwait(false)) + { + throw new Exception($"{UpstreamPath} has local changes. The driver is stamped with the commit, so build it from a clean checkout."); + } + + string coreDirectory = Path.Combine(UpstreamPath, "packages", "playwright-core"); + // npm ci removes node_modules, and the stamp with it. + string buildStamp = Path.Combine(UpstreamPath, "node_modules", ".playwright-dotnet-build"); + bool built = File.Exists(buildStamp) + && File.ReadAllText(buildStamp).Trim() == DriverVersion + && Directory.Exists(Path.Combine(coreDirectory, "lib")); + if (!built) + { + await RunNpmAsync(UpstreamPath, "ci", "--no-audit", "--no-fund").ConfigureAwait(false); + await RunNpmAsync(UpstreamPath, "run", "build").ConfigureAwait(false); + File.WriteAllText(buildStamp, DriverVersion + "\n"); + } - string tgz = Path.Combine(tempDirectory, $"playwright-core-{DriverVersion}.tgz"); - if (!File.Exists(tgz)) + string tempDirectory = Directory.CreateTempSubdirectory("playwright-core-").FullName; + try + { + await RunNpmAsync(coreDirectory, "pack", "--pack-destination", tempDirectory).ConfigureAwait(false); + string[] tgzs = Directory.GetFiles(tempDirectory, "playwright-core-*.tgz"); + if (tgzs.Length != 1) { - throw new Exception($"npm pack did not produce {tgz}"); + throw new Exception($"npm pack did not produce one playwright-core-*.tgz in {tempDirectory}"); } + string tgz = tgzs.Single(); using var stream = File.OpenRead(tgz); using var gzip = new GZipStream(stream, CompressionMode.Decompress); @@ -263,7 +393,14 @@ private async Task DownloadPlaywrightPackageAsync(string driversDirectory) { Directory.Delete(tempDirectory, true); } - Console.WriteLine($"Extracted playwright-core {DriverVersion}"); + + // Upstream copies these into the package before it publishes it + // (LICENCE_FILES in utils/workspace.js). + foreach (string name in new[] { "NOTICE", "LICENSE" }) + { + File.Copy(Path.Combine(UpstreamPath, name), Path.Combine(driversDirectory, "package", name), overwrite: true); + } + Console.WriteLine($"Built playwright-core at {DriverVersion}"); } private async Task DownloadNodeAsync(HttpClient client, string driversDirectory, string platformId, string nodeSuffix) @@ -332,17 +469,15 @@ await WithRetriesAsync(url, async () => Console.WriteLine($"Extracted Node.js {NodeVersion} for {platformId}"); } - private async Task UpdateBrowserVersionsAsync(string basePath, string driverVersion) + private async Task UpdateBrowserVersionsAsync() { try { - string readmePath = Path.Combine(basePath, "README.md"); - string playwrightVersion = driverVersion.Contains("-") ? driverVersion.Substring(0, driverVersion.IndexOf("-")) : driverVersion; + string readmePath = Path.Combine(BasePath, "README.md"); var regex = new Regex("(.*?)", RegexOptions.Compiled); - var basePlaywrightDir = Environment.GetEnvironmentVariable("PW_SRC_DIR") ?? Path.Combine(Environment.CurrentDirectory, "..", "playwright"); - var readme = File.ReadAllText(Path.Combine(basePlaywrightDir, "README.md")); + var readme = File.ReadAllText(Path.Combine(UpstreamPath, "README.md")); static string ReplaceBrowserVersion(string content, MatchCollection browserMatches) { foreach (Match match in browserMatches) @@ -356,20 +491,24 @@ static string ReplaceBrowserVersion(string content, MatchCollection browserMatch var browserMatches = regex.Matches(readme); string readmeText = await File.ReadAllTextAsync(readmePath).ConfigureAwait(false); - await File.WriteAllTextAsync(readmePath, ReplaceBrowserVersion(readmeText, browserMatches)).ConfigureAwait(false); + string newReadmeText = ReplaceBrowserVersion(readmeText, browserMatches); + if (newReadmeText != readmeText) + { + await File.WriteAllTextAsync(readmePath, newReadmeText).ConfigureAwait(false); + } } catch (Exception e) { Console.ForegroundColor = ConsoleColor.Yellow; Console.WriteLine("WARNING: Could not update the browser versions in the README file."); - Console.WriteLine($"This is usually due to the readme file not yet existing for {driverVersion}."); Console.WriteLine(e.Message); + Console.ResetColor(); } } } -[Verb("download-drivers")] -internal class DownloadDriversOptions +[Verb("build-driver")] +internal class BuildDriverOptions { [Option(Required = true, HelpText = "Solution path.")] public string BasePath { get; set; } diff --git a/src/tools/Playwright.Tooling/Program.cs b/src/tools/Playwright.Tooling/Program.cs index 67f5a3a1d..59f275d6b 100644 --- a/src/tools/Playwright.Tooling/Program.cs +++ b/src/tools/Playwright.Tooling/Program.cs @@ -31,7 +31,7 @@ internal static class Program { internal static async Task Main(string[] args) { - ParserResult result = Parser.Default.ParseArguments(args); - await result.WithParsedAsync(DriverDownloader.RunAsync).ConfigureAwait(false); + ParserResult result = Parser.Default.ParseArguments(args); + await result.WithParsedAsync(DriverBuilder.RunAsync).ConfigureAwait(false); } } diff --git a/utils/docker/build.sh b/utils/docker/build.sh index 32a7ab0c2..9de260e25 100755 --- a/utils/docker/build.sh +++ b/utils/docker/build.sh @@ -10,8 +10,7 @@ if [[ ($1 == '--help') || ($1 == '-h') || ($1 == '') || ($2 == '') ]]; then echo "" echo " docker run --rm -it playwright:localbuild-noble /bin/bash" echo "" - echo "NOTE: this requires on Playwright dependencies to be installed with 'npm install'" - echo " and Playwright itself being built with 'npm run build'" + echo "NOTE: this requires the driver to be built with './build.sh --build-driver'" echo "" exit 0 fi