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
chore: include actual value in the elementState
  • Loading branch information
pavelfeldman committed Jan 7, 2025
commit b5468d13f0fed81175bd5e568fcdf34af8d00c50
10 changes: 8 additions & 2 deletions packages/playwright-core/src/server/dom.ts
Original file line number Diff line number Diff line change
Expand Up @@ -778,7 +778,9 @@ export class ElementHandle<T extends Node = Node> extends js.JSHandle<T> {
async _setChecked(progress: Progress, state: boolean, options: { position?: types.Point } & types.PointerActionWaitOptions): Promise<'error:notconnected' | 'done'> {
const isChecked = async () => {
const result = await this.evaluateInUtility(([injected, node]) => injected.elementState(node, 'checked'), {});
return throwRetargetableDOMError(result);
if (result === 'error:notconnected' || result.received === 'error:notconnected')
throwElementIsNotAttached();
return result.matches;
};
await this._markAsTargetElement(progress.metadata);
if (await isChecked() === state)
Expand Down Expand Up @@ -913,10 +915,14 @@ export class ElementHandle<T extends Node = Node> extends js.JSHandle<T> {

export function throwRetargetableDOMError<T>(result: T | 'error:notconnected'): T {
if (result === 'error:notconnected')
throw new Error('Element is not attached to the DOM');
throwElementIsNotAttached();
return result;
}

export function throwElementIsNotAttached(): never {
throw new Error('Element is not attached to the DOM');
}

export function assertDone(result: 'done'): void {
// This function converts 'done' to void and ensures typescript catches unhandled errors.
}
Expand Down
28 changes: 5 additions & 23 deletions packages/playwright-core/src/server/frames.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1301,7 +1301,9 @@ export class Frame extends SdkObject {
const result = await this._callOnElementOnceMatches(metadata, selector, (injected, element, data) => {
return injected.elementState(element, data.state);
}, { state }, options, scope);
return dom.throwRetargetableDOMError(result);
if (result.received === 'error:notconnected')
dom.throwElementIsNotAttached();
return result.matches;
}

async isVisible(metadata: CallMetadata, selector: string, options: types.StrictOptions = {}, scope?: dom.ElementHandle): Promise<boolean> {
Expand All @@ -1319,8 +1321,8 @@ export class Frame extends SdkObject {
return false;
return await resolved.injected.evaluate((injected, { info, root }) => {
const element = injected.querySelector(info.parsed, root || document, info.strict);
const state = element ? injected.elementState(element, 'visible') : false;
return state === 'error:notconnected' ? false : state;
const state = element ? injected.elementState(element, 'visible') : { matches: false, received: 'error:notconnected' };
return state.matches;
}, { info: resolved.info, root: resolved.frame === this ? scope : undefined });
} catch (e) {
if (js.isJavaScriptErrorInEvaluate(e) || isInvalidSelectorError(e) || isSessionClosedError(e))
Expand Down Expand Up @@ -1809,26 +1811,6 @@ function verifyLifecycle(name: string, waitUntil: types.LifecycleEvent): types.L
}

function renderUnexpectedValue(expression: string, received: any): string {
if (expression === 'to.be.checked')
return received ? 'checked' : 'unchecked';
if (expression === 'to.be.unchecked')
return received ? 'unchecked' : 'checked';
if (expression === 'to.be.visible')
return received ? 'visible' : 'hidden';
if (expression === 'to.be.hidden')
return received ? 'hidden' : 'visible';
if (expression === 'to.be.enabled')
return received ? 'enabled' : 'disabled';
if (expression === 'to.be.disabled')
return received ? 'disabled' : 'enabled';
if (expression === 'to.be.editable')
return received ? 'editable' : 'readonly';
if (expression === 'to.be.readonly')
return received ? 'readonly' : 'editable';
if (expression === 'to.be.empty')
return received ? 'empty' : 'not empty';
if (expression === 'to.be.focused')
return received ? 'focused' : 'not focused';
if (expression === 'to.match.aria')
return received ? received.raw : received;
return received;
Expand Down
119 changes: 74 additions & 45 deletions packages/playwright-core/src/server/injected/injectedScript.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,8 +41,9 @@ import { parseYamlTemplate } from '@isomorphic/ariaSnapshot';

export type FrameExpectParams = Omit<channels.FrameExpectParams, 'expectedValue'> & { expectedValue?: any };

export type ElementStateWithoutStable = 'visible' | 'hidden' | 'enabled' | 'disabled' | 'editable' | 'checked' | 'unchecked';
export type ElementState = ElementStateWithoutStable | 'stable';
export type ElementState = 'visible' | 'hidden' | 'enabled' | 'disabled' | 'editable' | 'checked' | 'unchecked' | 'mixed' | 'stable';
export type ElementStateWithoutStable = Exclude<ElementState, 'stable'>;
export type ElementStateQueryResult = { matches: boolean, received?: string | 'error:notconnected' };

export type HitTargetInterceptionResult = {
stop: () => 'done' | { hitTargetDescription: string };
Expand Down Expand Up @@ -545,15 +546,15 @@ export class InjectedScript {
if (stableResult === false)
return { missingState: 'stable' };
if (stableResult === 'error:notconnected')
return stableResult;
return 'error:notconnected';
}
for (const state of states) {
if (state !== 'stable') {
const result = this.elementState(node, state);
if (result === false)
if (result.received === 'error:notconnected')
return 'error:notconnected';
if (!result.matches)
return { missingState: state };
if (result === 'error:notconnected')
return result;
}
}
}
Expand Down Expand Up @@ -608,38 +609,50 @@ export class InjectedScript {
return result;
}

elementState(node: Node, state: ElementStateWithoutStable): boolean | 'error:notconnected' {
const element = this.retarget(node, ['stable', 'visible', 'hidden'].includes(state) ? 'none' : 'follow-label');
elementState(node: Node, state: ElementStateWithoutStable): ElementStateQueryResult {
const element = this.retarget(node, ['visible', 'hidden'].includes(state) ? 'none' : 'follow-label');
if (!element || !element.isConnected) {
if (state === 'hidden')
return true;
return 'error:notconnected';
return { matches: true, received: 'hidden' };
return { matches: false, received: 'error:notconnected' };
}

if (state === 'visible')
return isElementVisible(element);
if (state === 'hidden')
return !isElementVisible(element);
if (state === 'visible' || state === 'hidden') {
const visible = isElementVisible(element);
return {
matches: state === 'visible' ? visible : !visible,
received: visible ? 'visible' : 'hidden'
};
}

const disabled = getAriaDisabled(element);
if (state === 'disabled')
return disabled;
if (state === 'enabled')
return !disabled;
if (state === 'disabled' || state === 'enabled') {
const disabled = getAriaDisabled(element);
return {
matches: state === 'disabled' ? disabled : !disabled,
received: disabled ? 'disabled' : 'enabled'
};
}

if (state === 'editable') {
const disabled = getAriaDisabled(element);
const readonly = getReadonly(element);
if (readonly === 'error')
throw this.createStacklessError('Element is not an <input>, <textarea>, <select> or [contenteditable] and does not have a role allowing [aria-readonly]');
return !disabled && !readonly;
return {
matches: !disabled && !readonly,
received: disabled ? 'disabled' : readonly ? 'readOnly' : 'editable'
};
}

if (state === 'checked' || state === 'unchecked') {
const need = state === 'checked';
if (state === 'checked' || state === 'unchecked' || state === 'mixed') {
const need = state === 'checked' ? true : state === 'unchecked' ? false : 'mixed';
const checked = getChecked(element, false);
if (checked === 'error')
throw this.createStacklessError('Not a checkbox or radio button');
return need === checked;
return {
matches: need === checked,
received: checked === true ? 'checked' : checked === false ? 'unchecked' : 'mixed',
};
}
throw this.createStacklessError(`Unexpected element state "${state}"`);
}
Expand Down Expand Up @@ -1220,44 +1233,60 @@ export class InjectedScript {

{
// Element state / boolean values.
let elementState: boolean | 'error:notconnected' | 'error:notcheckbox' | undefined;
let result: ElementStateQueryResult | undefined;
if (expression === 'to.have.attribute') {
elementState = element.hasAttribute(options.expressionArg);
const hasAttribute = element.hasAttribute(options.expressionArg);
result = {
matches: hasAttribute,
received: hasAttribute ? 'attribute present' : 'attribute not present',
};
} else if (expression === 'to.be.checked') {
elementState = this.elementState(element, 'checked');
result = this.elementState(element, 'checked');
} else if (expression === 'to.be.unchecked') {
elementState = this.elementState(element, 'unchecked');
result = this.elementState(element, 'unchecked');
} else if (expression === 'to.be.disabled') {
elementState = this.elementState(element, 'disabled');
result = this.elementState(element, 'disabled');
} else if (expression === 'to.be.editable') {
elementState = this.elementState(element, 'editable');
result = this.elementState(element, 'editable');
} else if (expression === 'to.be.readonly') {
elementState = !this.elementState(element, 'editable');
result = this.elementState(element, 'editable');
result.matches = !result.matches;
} else if (expression === 'to.be.empty') {
if (element.nodeName === 'INPUT' || element.nodeName === 'TEXTAREA')
elementState = !(element as HTMLInputElement).value;
else
elementState = !element.textContent?.trim();
if (element.nodeName === 'INPUT' || element.nodeName === 'TEXTAREA') {
const value = (element as HTMLInputElement).value;
result = { matches: !value, received: value ? 'notEmpty' : 'empty' };
} else {
const text = element.textContent?.trim();
result = { matches: !text, received: text ? 'notEmpty' : 'empty' };
}
} else if (expression === 'to.be.enabled') {
elementState = this.elementState(element, 'enabled');
result = this.elementState(element, 'enabled');
} else if (expression === 'to.be.focused') {
elementState = this._activelyFocused(element).isFocused;
const focused = this._activelyFocused(element).isFocused;
result = {
matches: focused,
received: focused ? 'focused' : 'inactive',
};
} else if (expression === 'to.be.hidden') {
elementState = this.elementState(element, 'hidden');
result = this.elementState(element, 'hidden');
} else if (expression === 'to.be.visible') {
elementState = this.elementState(element, 'visible');
result = this.elementState(element, 'visible');
} else if (expression === 'to.be.attached') {
elementState = true;
result = {
matches: true,
received: 'attached',
};
} else if (expression === 'to.be.detached') {
elementState = false;
result = {
matches: false,
received: 'attached',
};
}

if (elementState !== undefined) {
if (elementState === 'error:notcheckbox')
throw this.createStacklessError('Element is not a checkbox');
if (elementState === 'error:notconnected')
if (result) {
if (result.received === 'error:notconnected')
throw this.createStacklessError('Element is not connected');
return { received: elementState, matches: elementState };
return result;
}
}

Expand Down
27 changes: 11 additions & 16 deletions packages/playwright/src/matchers/matchers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,9 +42,8 @@ export function toBeAttached(
) {
const attached = !options || options.attached === undefined || options.attached;
const expected = attached ? 'attached' : 'detached';
const unexpected = attached ? 'detached' : 'attached';
const arg = attached ? '' : '{ attached: false }';
return toBeTruthy.call(this, 'toBeAttached', locator, 'Locator', expected, unexpected, arg, async (isNot, timeout) => {
return toBeTruthy.call(this, 'toBeAttached', locator, 'Locator', expected, arg, async (isNot, timeout) => {
return await locator._expect(attached ? 'to.be.attached' : 'to.be.detached', { isNot, timeout });
}, options);
}
Expand All @@ -56,9 +55,8 @@ export function toBeChecked(
) {
const checked = !options || options.checked === undefined || options.checked;
const expected = checked ? 'checked' : 'unchecked';
const unexpected = checked ? 'unchecked' : 'checked';
const arg = checked ? '' : '{ checked: false }';
return toBeTruthy.call(this, 'toBeChecked', locator, 'Locator', expected, unexpected, arg, async (isNot, timeout) => {
return toBeTruthy.call(this, 'toBeChecked', locator, 'Locator', expected, arg, async (isNot, timeout) => {
return await locator._expect(checked ? 'to.be.checked' : 'to.be.unchecked', { isNot, timeout });
}, options);
}
Expand All @@ -68,7 +66,7 @@ export function toBeDisabled(
locator: LocatorEx,
options?: { timeout?: number },
) {
return toBeTruthy.call(this, 'toBeDisabled', locator, 'Locator', 'disabled', 'enabled', '', async (isNot, timeout) => {
return toBeTruthy.call(this, 'toBeDisabled', locator, 'Locator', 'disabled', '', async (isNot, timeout) => {
return await locator._expect('to.be.disabled', { isNot, timeout });
}, options);
}
Expand All @@ -80,9 +78,8 @@ export function toBeEditable(
) {
const editable = !options || options.editable === undefined || options.editable;
const expected = editable ? 'editable' : 'readOnly';
const unexpected = editable ? 'readOnly' : 'editable';
const arg = editable ? '' : '{ editable: false }';
return toBeTruthy.call(this, 'toBeEditable', locator, 'Locator', expected, unexpected, arg, async (isNot, timeout) => {
return toBeTruthy.call(this, 'toBeEditable', locator, 'Locator', expected, arg, async (isNot, timeout) => {
return await locator._expect(editable ? 'to.be.editable' : 'to.be.readonly', { isNot, timeout });
}, options);
}
Expand All @@ -92,7 +89,7 @@ export function toBeEmpty(
locator: LocatorEx,
options?: { timeout?: number },
) {
return toBeTruthy.call(this, 'toBeEmpty', locator, 'Locator', 'empty', 'notEmpty', '', async (isNot, timeout) => {
return toBeTruthy.call(this, 'toBeEmpty', locator, 'Locator', 'empty', '', async (isNot, timeout) => {
return await locator._expect('to.be.empty', { isNot, timeout });
}, options);
}
Expand All @@ -104,9 +101,8 @@ export function toBeEnabled(
) {
const enabled = !options || options.enabled === undefined || options.enabled;
const expected = enabled ? 'enabled' : 'disabled';
const unexpected = enabled ? 'disabled' : 'enabled';
const arg = enabled ? '' : '{ enabled: false }';
return toBeTruthy.call(this, 'toBeEnabled', locator, 'Locator', expected, unexpected, arg, async (isNot, timeout) => {
return toBeTruthy.call(this, 'toBeEnabled', locator, 'Locator', expected, arg, async (isNot, timeout) => {
return await locator._expect(enabled ? 'to.be.enabled' : 'to.be.disabled', { isNot, timeout });
}, options);
}
Expand All @@ -116,7 +112,7 @@ export function toBeFocused(
locator: LocatorEx,
options?: { timeout?: number },
) {
return toBeTruthy.call(this, 'toBeFocused', locator, 'Locator', 'focused', 'inactive', '', async (isNot, timeout) => {
return toBeTruthy.call(this, 'toBeFocused', locator, 'Locator', 'focused', '', async (isNot, timeout) => {
return await locator._expect('to.be.focused', { isNot, timeout });
}, options);
}
Expand All @@ -126,7 +122,7 @@ export function toBeHidden(
locator: LocatorEx,
options?: { timeout?: number },
) {
return toBeTruthy.call(this, 'toBeHidden', locator, 'Locator', 'hidden', 'visible', '', async (isNot, timeout) => {
return toBeTruthy.call(this, 'toBeHidden', locator, 'Locator', 'hidden', '', async (isNot, timeout) => {
return await locator._expect('to.be.hidden', { isNot, timeout });
}, options);
}
Expand All @@ -138,9 +134,8 @@ export function toBeVisible(
) {
const visible = !options || options.visible === undefined || options.visible;
const expected = visible ? 'visible' : 'hidden';
const unexpected = visible ? 'hidden' : 'visible';
const arg = visible ? '' : '{ visible: false }';
return toBeTruthy.call(this, 'toBeVisible', locator, 'Locator', expected, unexpected, arg, async (isNot, timeout) => {
return toBeTruthy.call(this, 'toBeVisible', locator, 'Locator', expected, arg, async (isNot, timeout) => {
return await locator._expect(visible ? 'to.be.visible' : 'to.be.hidden', { isNot, timeout });
}, options);
}
Expand All @@ -150,7 +145,7 @@ export function toBeInViewport(
locator: LocatorEx,
options?: { timeout?: number, ratio?: number },
) {
return toBeTruthy.call(this, 'toBeInViewport', locator, 'Locator', 'in viewport', 'outside viewport', '', async (isNot, timeout) => {
return toBeTruthy.call(this, 'toBeInViewport', locator, 'Locator', 'in viewport', '', async (isNot, timeout) => {
return await locator._expect('to.be.in.viewport', { isNot, expectedNumber: options?.ratio, timeout });
}, options);
}
Expand Down Expand Up @@ -232,7 +227,7 @@ export function toHaveAttribute(
}
}
if (expected === undefined) {
return toBeTruthy.call(this, 'toHaveAttribute', locator, 'Locator', 'have attribute', 'not have attribute', '', async (isNot, timeout) => {
return toBeTruthy.call(this, 'toHaveAttribute', locator, 'Locator', 'have attribute', '', async (isNot, timeout) => {
return await locator._expect('to.have.attribute', { expressionArg: name, isNot, timeout });
}, options);
}
Expand Down
Loading