Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
83 changes: 70 additions & 13 deletions .claude/skills/playwright-roll/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,34 +16,91 @@ Now, with the driver version known, always start with running the roll script to
./build.sh --roll <driver-version>
```

Afterwards, work through the list of changes that need to be backported.
You can find a list of pull requests that might need to be taking into account in the issue titled "Backport changes". Ignore the items that are already checked off.
Afterwards, walk through the upstream changes that affect the Java client and port the relevant ones.

Some items may be irrelevant to the .NET implementation - feel free to check with the upstream.
## Determining what to port

Some items may be connected, for example when the API has changed multiple times. In this case, handle them alltogether, aligning with the latest change. Check upstream to see the latest implementation.
List the upstream commits that touched a client-relevant path since the last release. The paths cover everything that can change the public Java surface or the wire protocol:

Otherwise, work through items one-by-one.
- `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/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/src/protocol.yml` and `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.

Rolling includes:
- updating client implementation to match changes in the upstream JS implementation (see ../playwright/packages/playwright-core/src/client)
- adding a couple of new tests to verify new/changed functionality
```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/src/protocol.yml' \
'packages/protocol/spec/'
```

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 <sha>`) 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.
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:

```
../playwright/packages/playwright-core/src/client/browserContext.ts
../playwright/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.

Key translation rules:

**Protocol calls** — `await this._channel.methodName(params)` → `SendMessageToServerAsync("methodName", paramsDictionary)`

**Extracting a returned channel object from a result** — JS uses `SomeClass.from(result.foo)` which resolves the JS-side object for a channel reference. In .NET, extract it from the connection: `.GetObject<Foo>("foo", _connection)`

**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.

**Update tests similarly to upstream.** When upstream removes or modifies a test, apply similar changes.

## Renaming generated types

When the API generator produces an unhelpful name for a return type (e.g. `Bind` instead of `BrowserBindResult`), you can control it by adding a struct alias in the upstream docs.

In the docs markdown file (e.g. `docs/src/api/class-browser.md`), change the return type from `<[Object]>` to `<[Object=DesiredName]>`:
In the docs markdown file (e.g. `docs/src/api/class-browser.md`), add `alias` (applies to all languages) or `alias-csharp` (overrides for .NET only) to the `<[Object]>` type:

```diff
-- returns: <[Object]>
+- returns: <[Object=BrowserBindResult]>
- `endpoint` <[string]>
+- returns: <[Object]>
+ - 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 <version>` 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.

## Running the full test suite

After porting, run the chromium suite end-to-end before opening the PR — many regressions only surface in older tests that depended on now-changed behavior. Tests take ~8–10 minutes:

```bash
BROWSER=chromium dotnet test ./src/Playwright.Tests/Playwright.Tests.csproj \
-c Debug -f net8.0 --logger:"console;verbosity=detailed" > /tmp/test-results.txt 2>&1
grep "^ Failed " /tmp/test-results.txt # list failures
tail -5 /tmp/test-results.txt # summary
```

The `=Name` syntax sets `structName` on the parsed type, which the .NET generator uses directly as the class name. After making this change, re-run `./build.sh --roll <version>` to regenerate, then update any hand-written implementation code to use the new type name.
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 (`../`).
- When updating checkboxes, store the issue content into /tmp and edit it there, then update the issue based on the file.
- Use the "gh" cli to interact with GitHub.
- When running git commands against an upstream repo, always use `git -C /path/to/repo <subcommand>` instead of `cd /path/to/repo && git <subcommand>`.
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,9 @@

| | Linux | macOS | Windows |
| :--- | :---: | :---: | :---: |
| Chromium <!-- GEN:chromium-version -->147.0.7727.15<!-- GEN:stop --> | ✅ | ✅ | ✅ |
| Chromium <!-- GEN:chromium-version -->148.0.7778.96<!-- GEN:stop --> | ✅ | ✅ | ✅ |
| WebKit <!-- GEN:webkit-version -->26.4<!-- GEN:stop --> | ✅ | ✅ | ✅ |
| Firefox <!-- GEN:firefox-version -->148.0.2<!-- GEN:stop --> | ✅ | ✅ | ✅ |
| Firefox <!-- GEN:firefox-version -->150.0.2<!-- GEN:stop --> | ✅ | ✅ | ✅ |

Playwright for .NET is the official language port of [Playwright](https://playwright.dev), the library to automate [Chromium](https://www.chromium.org/Home), [Firefox](https://www.mozilla.org/en-US/firefox/new/) and [WebKit](https://webkit.org/) with a single API. Playwright is built to enable cross-browser web automation that is **ever-green**, **capable**, **reliable** and **fast**.

Expand Down
2 changes: 1 addition & 1 deletion src/Common/Version.props
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
<PropertyGroup>
<AssemblyVersion>1.59.0</AssemblyVersion>
<PackageVersion>$(AssemblyVersion)</PackageVersion>
<DriverVersion>1.59.1</DriverVersion>
<DriverVersion>1.60.0</DriverVersion>
<ReleaseVersion>$(AssemblyVersion)</ReleaseVersion>
<FileVersion>$(AssemblyVersion)</FileVersion>
<NoDefaultExcludes>true</NoDefaultExcludes>
Expand Down
46 changes: 23 additions & 23 deletions src/Playwright.TestingHarnessTest/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion src/Playwright.TestingHarnessTest/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
"name": "playwright.testingharnesstest",
"private": true,
"devDependencies": {
"@playwright/test": "1.59.1",
"@playwright/test": "1.60.0",
"@types/node": "^22.12.0",
"fast-xml-parser": "^4.5.0"
}
Expand Down
2 changes: 1 addition & 1 deletion src/Playwright.TestingHarnessTest/tests/baseTest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ export const test = base.extend<{
launchServer: async ({ playwright }, use) => {
const servers: BrowserServer[] = [];
await use(async ({port}: {port: number}) => {
servers.push(await playwright.chromium.launchServer({ port }));
servers.push(await playwright.chromium.launchServer({ host: '127.0.0.1', port }));
});
for (const server of servers)
await server.close();
Expand Down
24 changes: 24 additions & 0 deletions src/Playwright.Tests/Assertions/PageAssertionsTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -90,4 +90,28 @@ public async Task ShouldSupportToHaveURLAsync()
await Expect(Page).ToHaveURLAsync("DATA:teXT/HTml,<div>a</div>", new() { IgnoreCase = true });
await Expect(Page).ToHaveURLAsync(new Regex("DATA:teXT/HTml,<div>a</div>"), new() { IgnoreCase = true });
}

[PlaywrightTest("playwright-test/playwright.expect.misc.spec.ts", "should support toMatchAriaSnapshot on page")]
public async Task ShouldSupportToMatchAriaSnapshotOnPage()
{
await Page.SetContentAsync("<h1>title</h1><button>click me</button>");
await Expect(Page).ToMatchAriaSnapshotAsync(@"
- heading ""title""
- button ""click me""
");

var exception = await PlaywrightAssert.ThrowsAsync<PlaywrightException>(() => Expect(Page).ToMatchAriaSnapshotAsync(@"
- heading ""missing""
", new() { Timeout = 100 }));
StringAssert.Contains("Page expected to match Aria snapshot", exception.Message);
}

[PlaywrightTest("playwright-test/playwright.expect.misc.spec.ts", "should include aria snapshot in failure message")]
public async Task ShouldIncludeAriaSnapshotInFailureMessage()
{
await Page.SetContentAsync("<h1>actual title</h1>");
var exception = await PlaywrightAssert.ThrowsAsync<PlaywrightException>(() => Expect(Page.Locator("h1")).ToHaveTextAsync("wrong", new() { Timeout = 100 }));
StringAssert.Contains("Aria snapshot", exception.Message);
StringAssert.Contains("heading", exception.Message);
}
}
58 changes: 58 additions & 0 deletions src/Playwright.Tests/BrowserContextEventsTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -216,4 +216,62 @@ public async Task WebErrorEventShouldWork()
Assert.AreEqual(Page, webError.Page);
StringAssert.Contains("boom", webError.Error);
}

[PlaywrightTest("browsercontext-events.spec.ts", "weberror event should include location")]
public async Task WebErrorEventShouldIncludeLocation()
{
var tsc = new TaskCompletionSource<IWebError>();
Context.WebError += (sender, e) => tsc.TrySetResult(e);
await Page.SetContentAsync(@"<script>throw new Error(""boom"")</script>");
var webError = await tsc.Task;
Assert.IsNotNull(webError.Location);
StringAssert.Contains("boom", webError.Error);
}

[PlaywrightTest("browsercontext-events.spec.ts", "should fire pageLoad")]
public async Task ShouldFirePageLoad()
{
var tcs = new TaskCompletionSource<IPage>();
Context.PageLoad += (_, p) => tcs.TrySetResult(p);
var page = await Context.NewPageAsync();
await page.GotoAsync(Server.EmptyPage);
var loaded = await tcs.Task;
Assert.AreSame(page, loaded);
}

[PlaywrightTest("browsercontext-events.spec.ts", "should fire pageClose")]
public async Task ShouldFirePageClose()
{
var tcs = new TaskCompletionSource<IPage>();
Context.PageClose += (_, p) => tcs.TrySetResult(p);
var page = await Context.NewPageAsync();
await page.CloseAsync();
var closed = await tcs.Task;
Assert.AreSame(page, closed);
}

[PlaywrightTest("browsercontext-events.spec.ts", "should fire frameAttached and frameNavigated")]
public async Task ShouldFireFrameAttachedAndFrameNavigated()
{
var attachedTcs = new TaskCompletionSource<IFrame>();
var navigatedTcs = new TaskCompletionSource<IFrame>();
Context.FrameAttached += (_, f) =>
{
if (f.ParentFrame != null)
{
attachedTcs.TrySetResult(f);
}
};
Context.FrameNavigated += (_, f) =>
{
if (f.ParentFrame != null)
{
navigatedTcs.TrySetResult(f);
}
};
var page = await Context.NewPageAsync();
await page.GotoAsync(Server.Prefix + "/frames/one-frame.html");
await attachedTcs.Task;
await navigatedTcs.Task;
}
}
39 changes: 0 additions & 39 deletions src/Playwright.Tests/BrowserContextExposeFunctionTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -103,43 +103,4 @@ public async Task ShouldBeCallableFromInsideAddInitScript()
CollectionAssert.Contains(args, "page");
}

[PlaywrightTest("browsercontext-expose-function.spec.ts", "exposeBindingHandle should work")]
public async Task ExposeBindingHandleShouldWork()
{
IJSHandle target = null;
await Context.ExposeBindingAsync(
"logme",
(BindingSource _, IJSHandle t) =>
{
target = t;
return 17;
});

var page = await Context.NewPageAsync();
int result = await page.EvaluateAsync<int>(@"async function() {
return window['logme']({ foo: 42 });
}");

Assert.AreEqual(42, await target.EvaluateAsync<int>("x => x.foo"));
Assert.AreEqual(17, result);
}

public async Task ExposeBindingHandleLikeInDocumentation()
{
var result = new TaskCompletionSource<string>();
var page = await Context.NewPageAsync();
await Context.ExposeBindingAsync("clicked", async (BindingSource _, IJSHandle t) =>
{
return result.TrySetResult(await t.AsElement().TextContentAsync());
});

await page.SetContentAsync("<script>\n" +
" document.addEventListener('click', event => window.clicked(event.target));\n" +
"</script>\n" +
"<div>Click me</div>\n" +
"<div>Or click me</div>\n");

await page.ClickAsync("div");
Assert.AreEqual("Click me", await result.Task);
}
}
Loading
Loading