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
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 @@ -56,7 +56,7 @@ expect.soft(page.get_by_role("heading", name="Make another order")).to_be_visibl
Note that soft assertions only work with the
[`pytest-playwright`](https://pypi.org/project/pytest-playwright/) (or
[`pytest-playwright-asyncio`](https://pypi.org/project/pytest-playwright-asyncio/))
plugin, version `0.7.3` or newer.
plugin, version `0.8.0` or newer.

## Custom Expect Message
* langs: python, csharp
Expand Down
25 changes: 18 additions & 7 deletions packages/injected/src/ariaSnapshotDistiller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
* limitations under the License.
*/

import { hasPointerCursor } from '@isomorphic/ariaSnapshot';
import { normalizeWhiteSpace } from '@isomorphic/stringUtils';

import type * as aria from '@isomorphic/ariaSnapshot';
Expand Down Expand Up @@ -102,6 +103,11 @@ function isLeafGeneric(node: aria.AriaNode): boolean {
return node.role === 'generic' && node.children.every(child => typeof child === 'string');
}

// Removing the click target root would hide an actionable element from the snapshot.
function isClickTargetRoot(node: aria.AriaNode, ctx: DistillerContext): boolean {
return !!node.ref && hasPointerCursor(node) && !ctx.ancestors.some(ancestor => !!ancestor.ref && hasPointerCursor(ancestor));
}

// The tree builder emits raw text tokens - text nodes, CSS content, block spacing markers - as
// string children. Coalesce the adjacent ones, normalize whitespace and drop the empties, then
// drop a lone text child that merely repeats the node's accessible name. Runs on `exit`, so the
Expand Down Expand Up @@ -137,22 +143,27 @@ const mergeStringChildren: DistillerPlugin = {
// Only unwrap a generic that encloses at most one element, logical grouping still makes sense,
// even if it is not ref-able. The decision is made on `exit` - whether the node encloses a single
// ref-bearing child is only known after its own descendants were unwrapped - so nested wrappers
// collapse bottom-up.
// collapse bottom-up. A generic emptied by the other plugins is dropped, unless it is the
// click target root, for example an icon-only button.
const unwrapSingleChildGenerics: DistillerPlugin = {
name: 'unwrapSingleChildGenerics',
exit(node: aria.AriaNode): 'unwrap' | void {
if (node.role === 'generic' && !node.name && node.children.length <= 1 && node.children.every(child => typeof child !== 'string' && !!child.ref))
return 'unwrap';
exit(node: aria.AriaNode, ctx: DistillerContext): 'unwrap' | void {
if (node.role !== 'generic' || node.name || node.children.length > 1 || !node.children.every(child => typeof child !== 'string' && !!child.ref))
return;
if (!node.children.length && isClickTargetRoot(node, ctx))
return;
return 'unwrap';
},
};

// A decorative image - role `img` with no accessible name and no content - carries no
// information. The decision is made on `exit` - whether the node has content is only known after
// `mergeStringChildren` dropped the empty text tokens.
// `mergeStringChildren` dropped the empty text tokens. A clickable image outside of any clickable
// container is not decorative though - e.g. a bare svg icon acting as a button - and is kept.
const removeNamelessImages: DistillerPlugin = {
name: 'removeNamelessImages',
exit(node: aria.AriaNode): 'remove' | void {
if (node.role === 'img' && !node.name && !node.children.length)
exit(node: aria.AriaNode, ctx: DistillerContext): 'remove' | void {
if (node.role === 'img' && !node.name && !node.children.length && !isClickTargetRoot(node, ctx))
return 'remove';
},
};
Expand Down
16 changes: 11 additions & 5 deletions packages/isomorphic/selectorParser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,8 +92,8 @@ export function parseSelector(selector: string): ParsedSelector {
}

// Splits a selector into per-frame chunks separated by "enter-frame" boundaries. When the selector
// starts with the "pierce-frames" token, `pierce` is set globally and no "enter-frame" boundaries
// are allowed (piercing already searches every descendant frame), so `chunks` holds a single chunk.
// starts with the "pierce-frames" token, `pierce` is set globally and "enter-frame" tokens are
// preserved, so `chunks` holds a single chunk.
export function splitSelectorByFrame(selectorText: string): { pierce: boolean, chunks: ParsedSelector[] } {
const selector = parseSelector(selectorText);
const chunks: ParsedSelector[] = [];
Expand All @@ -113,10 +113,13 @@ export function splitSelectorByFrame(selectorText: string): { pierce: boolean, c
continue;
}
if (part.name === 'internal:control' && part.body === 'enter-frame') {
if (pierce)
throw new InvalidSelectorError(`Entering frames is not allowed while piercing frames, while parsing selector ${selectorText}`);
if (!chunk.parts.length)
const lastPart = chunk.parts[chunk.parts.length - 1];
if (!lastPart || (lastPart.name === 'internal:control' && lastPart.body === 'enter-frame'))
throw new InvalidSelectorError('Selector cannot start with entering frame, select the iframe first');
if (pierce) {
chunk.parts.push(part);
continue;
}
chunks.push(chunk);
chunk = { parts: [] };
chunkStartIndex = i + 1;
Expand All @@ -131,6 +134,9 @@ export function splitSelectorByFrame(selectorText: string): { pierce: boolean, c
throw new InvalidSelectorError(`Selector cannot be empty when piercing frames, while parsing selector ${selectorText}`);
throw new InvalidSelectorError(`Selector cannot end with entering frame, while parsing selector ${selectorText}`);
}
const lastPart = chunk.parts[chunk.parts.length - 1];
if (lastPart.name === 'internal:control' && lastPart.body === 'enter-frame')
throw new InvalidSelectorError(`Selector cannot end with entering frame, while parsing selector ${selectorText}`);
chunks.push(chunk);
if (typeof selector.capture === 'number' && typeof chunks[chunks.length - 1].capture !== 'number')
throw new InvalidSelectorError(`Can not capture the selector before diving into the frame. Only use * after the last frame has been selected`);
Expand Down
25 changes: 1 addition & 24 deletions packages/playwright-core/browsers.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,43 +15,20 @@
"browserVersion": "151.0.7922.47",
"title": "Chrome Headless Shell"
},
{
"name": "chromium-tip-of-tree",
"revision": "1433",
"installByDefault": false,
"browserVersion": "151.0.7893.0",
"title": "Chrome Canary for Testing"
},
{
"name": "chromium-tip-of-tree-headless-shell",
"revision": "1433",
"installByDefault": false,
"browserVersion": "151.0.7893.0",
"title": "Chrome Canary Headless Shell"
},
{
"name": "firefox",
"revision": "1539",
"installByDefault": true,
"browserVersion": "153.0",
"title": "Firefox"
},
{
"name": "firefox-beta",
"revision": "1526",
"installByDefault": false,
"browserVersion": "152.0b1",
"title": "Firefox Beta"
},
{
"name": "webkit",
"revision": "2340",
"installByDefault": true,
"revisionOverrides": {
"mac14": "2251",
"mac14-arm64": "2251",
"ubuntu20.04-x64": "2092",
"ubuntu20.04-arm64": "2092"
"mac14-arm64": "2251"
},
"browserVersion": "26.5",
"title": "WebKit"
Expand Down
4 changes: 0 additions & 4 deletions packages/playwright-core/src/client/locator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -207,8 +207,6 @@ export class Locator implements api.Locator {
}

frameLocator(selector: string): FrameLocator {
if (selectorPiercesFrames(this._selector))
throw new Error(`Entering frames is not allowed while piercing frames.`);
return new FrameLocator(this._frame, this._selector + ' >> ' + selector);
}

Expand Down Expand Up @@ -485,8 +483,6 @@ export class FrameLocator implements api.FrameLocator {
}

frameLocator(selector: string): FrameLocator {
if (selectorPiercesFrames(this._frameSelector))
throw new Error(`Entering frames is not allowed while piercing frames.`);
return new FrameLocator(this._frame, this._childSelector(selector));
}

Expand Down
2 changes: 0 additions & 2 deletions packages/playwright-core/src/server/chromium/chromium.ts
Original file line number Diff line number Diff line change
Expand Up @@ -417,8 +417,6 @@ export class Chromium extends BrowserType {
override getExecutableName(options: types.LaunchOptions): string {
if (options.channel && registry.isChromiumAlias(options.channel))
return 'chromium';
if (options.channel === 'chromium-tip-of-tree')
return options.headless ? 'chromium-tip-of-tree-headless-shell' : 'chromium-tip-of-tree';
if (options.channel)
return options.channel;
return options.headless ? 'chromium-headless-shell' : 'chromium';
Expand Down
20 changes: 18 additions & 2 deletions packages/playwright-core/src/server/frameSelectors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -136,7 +136,7 @@ export class FrameSelectors {
}

if (pierce) {
const parsed = chunks[0]; // Only one chunk is allowed with pierce.
const parsed = chunks[0]; // Only one chunk is allowed with pierce, it may contain enter-frame parts.
if (parsed.parts.some((part, index) => part.name === 'nth' && index !== parsed.parts.length - 1)) {
const locator = asLocator(this.frame._page.browserContext._browser.sdkLanguage(), selector);
throw new InvalidSelectorError(`nth can only be the last locator when piercing frames, while querying "${locator}"`);
Expand Down Expand Up @@ -186,6 +186,9 @@ export class FrameSelectors {
for (const [frame, startIndexes] of candidates) {
for (const startIndex of startIndexes) {
const suffix = infos.slice(startIndex);
// A leftover "enter-frame" token means we are not going to match anything in this frame.
if (suffix.some(info => isEnterFramePart(info.parsed.parts[0])))
continue;
const partialInfo: SelectorInfo = {
parsed: { parts: suffix.map(info => info.parsed.parts[0]) },
world: suffix.some(info => info.world === 'main') ? 'main' : 'utility',
Expand Down Expand Up @@ -225,8 +228,17 @@ export class FrameSelectors {
for (const element of all)
next.add(element);
}
const nextPart = index + 1 < infos.length ? infos[index + 1].parsed.parts[0] : undefined;
if (nextPart && nextPart.name === 'internal:control' && nextPart.body === 'enter-frame') {
// We must enter the iframe now, so stop matching any further.
for (const { frameElement, nextIndexes } of result) {
if (next.has(frameElement))
nextIndexes.push(index + 2);
}
break;
}
roots = [...next];
if (index + 1 < infos.length && !['nth', 'visible'].includes(infos[index + 1].parsed.parts[0].name)) {
if (nextPart && !['nth', 'visible'].includes(nextPart.name)) {
for (const { frameElement, nextIndexes } of result) {
if (roots.some(root => injected.utils.isInsideScope(root, frameElement)))
nextIndexes.push(index + 1);
Expand Down Expand Up @@ -342,6 +354,10 @@ export class FrameSelectors {
}
}

function isEnterFramePart(part: ParsedSelector['parts'][0]): boolean {
return part.name === 'internal:control' && part.body === 'enter-frame';
}

async function adoptIfNeeded<T extends Node>(handle: ElementHandle<T>, context: FrameExecutionContext): Promise<ElementHandle<T>> {
if (handle._context === context)
return handle;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,12 @@ export class RecorderSignalProcessor {
const lastAction = this._lastAction;
const signalThreshold = isUnderTest() ? 500 : 5000;

// A duplicate navigation signal to the URL we already recorded a goto for
// (e.g. a second commit for the same about:blank navigation) must not
// produce a second identical goto.
if (lastAction?.action.name === 'navigate' && lastAction.pageGuid === frame._page.guid && lastAction.action.url === frame.url())
return;

let generateGoto = false;
if (!lastAction)
generateGoto = true;
Expand Down
Loading
Loading