Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

chore: include actual value in the elementState #34245

Merged
merged 1 commit into from
Jan 7, 2025
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
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
Loading