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
6 changes: 4 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,18 +4,20 @@

## [Documentation](https://playwright.dev) | [API reference](https://playwright.dev/docs/api/class-playwright)

Playwright is a framework for Web Testing and Automation. It allows testing [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**.
Playwright is a framework for Web Testing and Automation. It allows testing [Chromium](https://www.chromium.org/Home)<sup>1</sup>, [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**.

| | Linux | macOS | Windows |
| :--- | :---: | :---: | :---: |
| Chromium <!-- GEN:chromium-version -->145.0.7632.18<!-- GEN:stop --> | :white_check_mark: | :white_check_mark: | :white_check_mark: |
| Chromium<sup>1</sup> <!-- GEN:chromium-version -->145.0.7632.18<!-- GEN:stop --> | :white_check_mark: | :white_check_mark: | :white_check_mark: |
| WebKit <!-- GEN:webkit-version -->26.0<!-- GEN:stop --> | :white_check_mark: | :white_check_mark: | :white_check_mark: |
| Firefox <!-- GEN:firefox-version -->146.0.1<!-- GEN:stop --> | :white_check_mark: | :white_check_mark: | :white_check_mark: |

Headless execution is supported for all browsers on all platforms. Check out [system requirements](https://playwright.dev/docs/intro#system-requirements) for details.

Looking for Playwright for [Python](https://playwright.dev/python/docs/intro), [.NET](https://playwright.dev/dotnet/docs/intro), or [Java](https://playwright.dev/java/docs/intro)?

<sup>1</sup> Playwright uses [Chrome for Testing](https://developer.chrome.com/blog/chrome-for-testing) by default.

## Installation

Playwright has its own test runner for end-to-end tests, we call it Playwright Test.
Expand Down
2 changes: 1 addition & 1 deletion docs/src/actionability.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ Playwright includes auto-retrying assertions that remove flakiness by waiting un
| [`method: LocatorAssertions.toHaveAttribute`] | Element has a DOM attribute |
| [`method: LocatorAssertions.toHaveClass`] | Element has a class property |
| [`method: LocatorAssertions.toHaveCount`] | List has exact number of children |
| [`method: LocatorAssertions.toHaveCSS`] | Element has CSS property |
| [`method: LocatorAssertions.toHaveCSS#1`] | Element has CSS property |
| [`method: LocatorAssertions.toHaveId`] | Element has an ID |
| [`method: LocatorAssertions.toHaveJSProperty`] | Element has a JavaScript property |
| [`method: LocatorAssertions.toHaveText`] | Element matches text |
Expand Down
39 changes: 33 additions & 6 deletions docs/src/api/class-locatorassertions.md
Original file line number Diff line number Diff line change
Expand Up @@ -351,7 +351,7 @@ Expected count.
* since: v1.20
* langs: python

The opposite of [`method: LocatorAssertions.toHaveCSS`].
The opposite of [`method: LocatorAssertions.toHaveCSS#1`].

### param: LocatorAssertions.NotToHaveCSS.name
* since: v1.18
Expand Down Expand Up @@ -1694,7 +1694,7 @@ Expected count.
### option: LocatorAssertions.toHaveCount.timeout = %%-csharp-java-python-assertions-timeout-%%
* since: v1.18

## async method: LocatorAssertions.toHaveCSS
## async method: LocatorAssertions.toHaveCSS#1
* since: v1.20
* langs:
- alias-java: hasCSS
Expand Down Expand Up @@ -1731,24 +1731,51 @@ var locator = Page.GetByRole(AriaRole.Button);
await Expect(locator).ToHaveCSSAsync("display", "flex");
```

### param: LocatorAssertions.toHaveCSS.name
### param: LocatorAssertions.toHaveCSS#1.name
* since: v1.18
- `name` <[string]>

CSS property name.

### param: LocatorAssertions.toHaveCSS.value
### param: LocatorAssertions.toHaveCSS#1.value
* since: v1.18
- `value` <[string]|[RegExp]>

CSS property value.

### option: LocatorAssertions.toHaveCSS.timeout = %%-js-assertions-timeout-%%
### option: LocatorAssertions.toHaveCSS#1.timeout = %%-js-assertions-timeout-%%
* since: v1.18

### option: LocatorAssertions.toHaveCSS.timeout = %%-csharp-java-python-assertions-timeout-%%
### option: LocatorAssertions.toHaveCSS#1.timeout = %%-csharp-java-python-assertions-timeout-%%
* since: v1.18


## async method: LocatorAssertions.toHaveCSS#2
* since: v1.58
* langs: js

Ensures the [Locator] resolves to an element with the given computed CSS properties. Only the listed properties are checked.

**Usage**

```js
const locator = page.getByRole('button');
await expect(locator).toHaveCSS({
display: 'flex',
backgroundColor: 'rgb(255, 0, 0)'
});
```

### param: LocatorAssertions.toHaveCSS#2.styles
* since: v1.58
- `styles` <[Object]>

CSS properties object. See [CSSStyleProperties](https://developer.mozilla.org/en-US/docs/Web/API/CSSStyleProperties) for available properties.

### option: LocatorAssertions.toHaveCSS#2.timeout = %%-js-assertions-timeout-%%
* since: v1.58


## async method: LocatorAssertions.toHaveId
* since: v1.20
* langs:
Expand Down
2 changes: 1 addition & 1 deletion docs/src/release-notes-js.md
Original file line number Diff line number Diff line change
Expand Up @@ -3196,7 +3196,7 @@ List of all new assertions:
- [`expect(locator).toHaveAttribute(name, value)`](./api/class-locatorassertions#locator-assertions-to-have-attribute)
- [`expect(locator).toHaveClass(expected)`](./api/class-locatorassertions#locator-assertions-to-have-class)
- [`expect(locator).toHaveCount(count)`](./api/class-locatorassertions#locator-assertions-to-have-count)
- [`expect(locator).toHaveCSS(name, value)`](./api/class-locatorassertions#locator-assertions-to-have-css)
- [`expect(locator).toHaveCSS(name, value)`](./api/class-locatorassertions#locator-assertions-to-have-css-1)
- [`expect(locator).toHaveId(id)`](./api/class-locatorassertions#locator-assertions-to-have-id)
- [`expect(locator).toHaveJSProperty(name, value)`](./api/class-locatorassertions#locator-assertions-to-have-js-property)
- [`expect(locator).toHaveText(expected, options)`](./api/class-locatorassertions#locator-assertions-to-have-text)
Expand Down
2 changes: 1 addition & 1 deletion docs/src/test-assertions-csharp-java-python.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ title: "Assertions"
| [`method: LocatorAssertions.toHaveAttribute`] | Element has a DOM attribute |
| [`method: LocatorAssertions.toHaveClass`] | Element has a class property |
| [`method: LocatorAssertions.toHaveCount`] | List has exact number of children |
| [`method: LocatorAssertions.toHaveCSS`] | Element has CSS property |
| [`method: LocatorAssertions.toHaveCSS#1`] | Element has CSS property |
| [`method: LocatorAssertions.toHaveId`] | Element has an ID |
| [`method: LocatorAssertions.toHaveJSProperty`] | Element has a JavaScript property |
| [`method: LocatorAssertions.toHaveRole`] | Element has a specific [ARIA role](https://www.w3.org/TR/wai-aria-1.2/#roles) |
Expand Down
2 changes: 1 addition & 1 deletion docs/src/test-assertions-js.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ Note that retrying assertions are async, so you must `await` them.
| [await expect(locator).toHaveAttribute()](./api/class-locatorassertions.md#locator-assertions-to-have-attribute) | Element has a DOM attribute |
| [await expect(locator).toHaveClass()](./api/class-locatorassertions.md#locator-assertions-to-have-class) | Element has specified CSS class property |
| [await expect(locator).toHaveCount()](./api/class-locatorassertions.md#locator-assertions-to-have-count) | List has exact number of children |
| [await expect(locator).toHaveCSS()](./api/class-locatorassertions.md#locator-assertions-to-have-css) | Element has CSS property |
| [await expect(locator).toHaveCSS()](./api/class-locatorassertions.md#locator-assertions-to-have-css-1) | Element has CSS property |
| [await expect(locator).toHaveId()](./api/class-locatorassertions.md#locator-assertions-to-have-id) | Element has an ID |
| [await expect(locator).toHaveJSProperty()](./api/class-locatorassertions.md#locator-assertions-to-have-js-property) | Element has a JavaScript property |
| [await expect(locator).toHaveRole()](./api/class-locatorassertions.md#locator-assertions-to-have-role) | Element has a specific [ARIA role](https://www.w3.org/TR/wai-aria-1.2/#roles) |
Expand Down
28 changes: 24 additions & 4 deletions packages/injected/src/injectedScript.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1423,7 +1423,7 @@ export class InjectedScript {
// Element state / boolean values.
let result: ElementStateQueryResult | undefined;
if (expression === 'to.have.attribute') {
const hasAttribute = element.hasAttribute(options.expressionArg);
const hasAttribute = element.hasAttribute(options.expressionArg || '');
result = {
matches: hasAttribute,
received: hasAttribute ? 'attribute present' : 'attribute not present',
Expand Down Expand Up @@ -1487,7 +1487,7 @@ export class InjectedScript {
// JS property
if (expression === 'to.have.property') {
let target = element;
const properties = options.expressionArg.split('.');
const properties = (options.expressionArg || '').split('.');
for (let i = 0; i < properties.length - 1; i++) {
if (typeof target !== 'object' || !(properties[i] in target))
return { received: undefined, matches: false };
Expand All @@ -1498,6 +1498,26 @@ export class InjectedScript {
return { received, matches };
}
}

{
// Computed style object
if (expression === 'to.have.css.object') {
const expected = (options.expectedValue ?? {}) as Record<string, string>;
const received: Record<string, string> = {};
let matches = true;
const style = this.window.getComputedStyle(element);
for (const [prop, value] of Object.entries(expected)) {
let computed = style[prop as any];
if (typeof computed !== 'string')
computed = '';
if (computed !== value)
matches = false;
received[prop] = computed;
}
return { received, matches };
}
}

{
// Viewport intersection
if (expression === 'to.be.in.viewport') {
Expand Down Expand Up @@ -1534,7 +1554,7 @@ export class InjectedScript {
// Single text value.
let received: string | undefined;
if (expression === 'to.have.attribute.value') {
const value = element.getAttribute(options.expressionArg);
const value = element.getAttribute(options.expressionArg || '');
if (value === null)
return { received: null, matches: false };
received = value;
Expand All @@ -1546,7 +1566,7 @@ export class InjectedScript {
matches: new ExpectedTextMatcher(options.expectedText[0]).matchesClassList(this, element.classList, /* partial */ expression === 'to.contain.class'),
};
} else if (expression === 'to.have.css') {
received = this.window.getComputedStyle(element).getPropertyValue(options.expressionArg);
received = this.window.getComputedStyle(element).getPropertyValue(options.expressionArg || '');
} else if (expression === 'to.have.id') {
received = element.id;
} else if (expression === 'to.have.text') {
Expand Down
2 changes: 1 addition & 1 deletion packages/playwright-core/src/protocol/validator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1921,7 +1921,7 @@ scheme.FrameWaitForSelectorResult = tObject({
scheme.FrameExpectParams = tObject({
selector: tOptional(tString),
expression: tString,
expressionArg: tOptional(tAny),
expressionArg: tOptional(tString),
expectedText: tOptional(tArray(tType('ExpectedTextValue'))),
expectedNumber: tOptional(tFloat),
expectedValue: tOptional(tType('SerializedArgument')),
Expand Down
30 changes: 18 additions & 12 deletions packages/playwright-core/src/server/agent/actionRunner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ import { parseAriaSnapshotUnsafe } from '../../utils/isomorphic/ariaSnapshot';
import { asLocatorDescription } from '../../utils/isomorphic/locatorGenerators';
import { yaml } from '../../utilsBundle';
import { serializeError } from '../errors';
import { rewriteErrorMessage } from '../../utils/isomorphic/stackTrace';
import { applySecrets, redactSecrets } from './context';

import type * as actions from './actions';
import type { Page } from '../page';
Expand All @@ -47,8 +49,10 @@ export async function runAction(progress: Progress, mode: 'generate' | 'run', pa
callMetadata.error = error ? serializeError(error) : undefined;
callMetadata.result = error ? undefined : result;
await frame.instrumentation.onAfterCall(frame, callMetadata);
if (error)
if (error) {
rewriteErrorMessage(error, redactSecrets(error.message, secrets));
throw error;
}
return result;
}

Expand Down Expand Up @@ -78,21 +82,20 @@ async function innerRunAction(progress: Progress, mode: 'generate' | 'run', page
});
break;
case 'selectOption':
await frame.selectOption(progress, action.selector, [], action.labels.map(a => ({ label: a })), { ...commonOptions });
const labels = action.labels.map(label => applySecrets(label, secrets));
await frame.selectOption(progress, action.selector, [], labels.map(a => ({ label: a })), { ...commonOptions });
break;
case 'pressKey':
await page.keyboard.press(progress, action.key);
break;
case 'pressSequentially': {
const secret = secrets?.find(s => s.name === action.text)?.value ?? action.text;
await frame.type(progress, action.selector, secret, { ...commonOptions });
await frame.type(progress, action.selector, applySecrets(action.text, secrets), { ...commonOptions });
if (action.submit)
await page.keyboard.press(progress, 'Enter');
break;
}
case 'fill': {
const secret = secrets?.find(s => s.name === action.text)?.value ?? action.text;
await frame.fill(progress, action.selector, secret, { ...commonOptions });
await frame.fill(progress, action.selector, applySecrets(action.text, secrets), { ...commonOptions });
if (action.submit)
await page.keyboard.press(progress, 'Enter');
break;
Expand All @@ -109,8 +112,9 @@ async function innerRunAction(progress: Progress, mode: 'generate' | 'run', page
}
case 'expectValue': {
if (action.type === 'textbox' || action.type === 'combobox' || action.type === 'slider') {
const expectedText = serializeExpectedTextValues([action.value]);
await runExpect(frame, progress, mode, action.selector, { expression: 'to.have.value', expectedText, isNot: !!action.isNot }, action.value, 'toHaveValue', 'expected');
const value = applySecrets(action.value, secrets);
const expectedText = serializeExpectedTextValues([value]);
await runExpect(frame, progress, mode, action.selector, { expression: 'to.have.value', expectedText, isNot: !!action.isNot }, value, 'toHaveValue', 'expected');
} else if (action.type === 'checkbox' || action.type === 'radio') {
const expectedValue = { checked: action.value === 'true' };
await runExpect(frame, progress, mode, action.selector, { selector: action.selector, expression: 'to.be.checked', expectedValue, isNot: !!action.isNot }, action.value ? 'checked' : 'unchecked', 'toBeChecked', '');
Expand All @@ -120,8 +124,9 @@ async function innerRunAction(progress: Progress, mode: 'generate' | 'run', page
break;
}
case 'expectAria': {
const expectedValue = parseAriaSnapshotUnsafe(yaml, action.template);
await runExpect(frame, progress, mode, 'body', { expression: 'to.match.aria', expectedValue, isNot: !!action.isNot }, '\n' + action.template, 'toMatchAriaSnapshot', 'expected');
const template = applySecrets(action.template, secrets);
const expectedValue = parseAriaSnapshotUnsafe(yaml, template);
await runExpect(frame, progress, mode, 'body', { expression: 'to.match.aria', expectedValue, isNot: !!action.isNot }, '\n' + template, 'toMatchAriaSnapshot', 'expected');
break;
}
case 'expectURL': {
Expand All @@ -135,8 +140,9 @@ async function innerRunAction(progress: Progress, mode: 'generate' | 'run', page
break;
}
case 'expectTitle': {
const expectedText = serializeExpectedTextValues([action.value], { normalizeWhiteSpace: true });
await runExpect(frame, progress, mode, undefined, { expression: 'to.have.title', expectedText, isNot: !!action.isNot }, action.value, 'toHaveTitle', 'expected');
const value = applySecrets(action.value, secrets);
const expectedText = serializeExpectedTextValues([value], { normalizeWhiteSpace: true });
await runExpect(frame, progress, mode, undefined, { expression: 'to.have.title', expectedText, isNot: !!action.isNot }, value, 'toHaveTitle', 'expected');
break;
}
}
Expand Down
38 changes: 22 additions & 16 deletions packages/playwright-core/src/server/agent/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -125,21 +125,21 @@ export class Context {
promises.push(request.response());
}

await progress.race([...promises, progress.wait(5000)]);
if (!promises.length)
if (promises.length)
await progress.race([...promises, progress.wait(5000)]);
else
await progress.wait(500);

return result;
}

async takeSnapshot(progress: Progress) {
const { full } = await this.page.snapshotForAI(progress, { doNotRenderActive: this.agentParams.doNotRenderActive });
// TODO: it seems like redactText should be here.
return full;
return redactSecrets(full, this.agentParams?.secrets);
}

async snapshotResult(progress: Progress, error?: Error): Promise<loopTypes.ToolResult> {
const snapshot = this._redactText(await this.takeSnapshot(progress));
const snapshot = await this.takeSnapshot(progress);

const text: string[] = [];
if (error)
Expand All @@ -166,17 +166,23 @@ export class Context {
}));
}

private _redactText(text: string): string {
const secrets = this.agentParams?.secrets;
if (!secrets)
return text;
}

const redactText = (text: string) => {
for (const { name, value } of secrets)
text = text.replaceAll(value, `<secret>${name}</secret>`);
return text;
};
export function redactSecrets(text: string, secrets: channels.NameValue[] | undefined): string {
if (!secrets)
return text;
for (const { name, value } of secrets)
text = text.replaceAll(value, `<secret>${name}</secret>`);
return text;
}

return redactText(text);
}
export function applySecrets(text: string, secrets: channels.NameValue[] | undefined): string {
if (!secrets)
return text;
const secret = secrets.find(s => s.name === text);
if (secret)
return secret.value;
for (const { name, value } of secrets)
text = text.replaceAll(`<secret>${name}</secret>`, value);
return text;
}
8 changes: 4 additions & 4 deletions packages/playwright-core/src/server/agent/pageAgent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,8 @@ export async function pageAgentPerform(progress: Progress, context: Context, use
const task = `
### Instructions
- Perform the following task on the page.
- Your reply should be a tool call that performs action the page".
- Your reply should be a tool call that performs action the page.
- If you see text surrounded by <secret></secret>, it is a secret and you should preserve it as such. It will be replaced with the actual value before the tool call.

### Task
${userTask}
Expand All @@ -63,7 +64,7 @@ export async function pageAgentExpect(progress: Progress, context: Context, expe
const task = `
### Instructions
- Call one of the "browser_expect_*" tools to verify / assert the condition.
- You can call exactly one tool and it can't be report_results, must be one of the assertion tools.
- If you see text surrounded by <secret></secret>, it is a secret and you should preserve it as such. It will be replaced with the actual value before the tool call.

### Expectation
${expectation}
Expand All @@ -78,6 +79,7 @@ export async function pageAgentExtract(progress: Progress, context: Context, que
const task = `
### Instructions
Extract the following information from the page. Do not perform any actions, just extract the information.
If you see text surrounded by <secret></secret>, it is a secret and you should preserve it as such. It will be replaced with the actual value before the tool call.

### Query
${query}`;
Expand All @@ -97,7 +99,6 @@ async function runLoop(progress: Progress, context: Context, toolDefinitions: To

const snapshot = await context.takeSnapshot(progress);
const { tools, callTool, reportedResult, refusedToPerformReason } = toolsForLoop(progress, context, toolDefinitions, { resultSchema, refuseToPerform: 'allow' });
const secrets = Object.fromEntries((context.agentParams.secrets || [])?.map(s => ([s.name, s.value])));

const apiCacheTextBefore = context.agentParams.apiCacheFile ?
await fs.promises.readFile(context.agentParams.apiCacheFile, 'utf-8').catch(() => '{}') : '{}';
Expand All @@ -116,7 +117,6 @@ async function runLoop(progress: Progress, context: Context, toolDefinitions: To
debug,
callTool,
tools,
secrets,
cache: apiCacheBefore,
...context.events,
});
Expand Down
Loading
Loading