Skip to content

Commit

Permalink
chore: include actual value in the elementState (#34245)
Browse files Browse the repository at this point in the history
  • Loading branch information
pavelfeldman authored Jan 7, 2025
1 parent 0008816 commit 0d34369
Show file tree
Hide file tree
Showing 6 changed files with 101 additions and 91 deletions.
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

0 comments on commit 0d34369

Please sign in to comment.