Skip to content

Commit 0d34369

Browse files
authored
chore: include actual value in the elementState (#34245)
1 parent 0008816 commit 0d34369

File tree

6 files changed

+101
-91
lines changed

6 files changed

+101
-91
lines changed

packages/playwright-core/src/server/dom.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -778,7 +778,9 @@ export class ElementHandle<T extends Node = Node> extends js.JSHandle<T> {
778778
async _setChecked(progress: Progress, state: boolean, options: { position?: types.Point } & types.PointerActionWaitOptions): Promise<'error:notconnected' | 'done'> {
779779
const isChecked = async () => {
780780
const result = await this.evaluateInUtility(([injected, node]) => injected.elementState(node, 'checked'), {});
781-
return throwRetargetableDOMError(result);
781+
if (result === 'error:notconnected' || result.received === 'error:notconnected')
782+
throwElementIsNotAttached();
783+
return result.matches;
782784
};
783785
await this._markAsTargetElement(progress.metadata);
784786
if (await isChecked() === state)
@@ -913,10 +915,14 @@ export class ElementHandle<T extends Node = Node> extends js.JSHandle<T> {
913915

914916
export function throwRetargetableDOMError<T>(result: T | 'error:notconnected'): T {
915917
if (result === 'error:notconnected')
916-
throw new Error('Element is not attached to the DOM');
918+
throwElementIsNotAttached();
917919
return result;
918920
}
919921

922+
export function throwElementIsNotAttached(): never {
923+
throw new Error('Element is not attached to the DOM');
924+
}
925+
920926
export function assertDone(result: 'done'): void {
921927
// This function converts 'done' to void and ensures typescript catches unhandled errors.
922928
}

packages/playwright-core/src/server/frames.ts

Lines changed: 5 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1301,7 +1301,9 @@ export class Frame extends SdkObject {
13011301
const result = await this._callOnElementOnceMatches(metadata, selector, (injected, element, data) => {
13021302
return injected.elementState(element, data.state);
13031303
}, { state }, options, scope);
1304-
return dom.throwRetargetableDOMError(result);
1304+
if (result.received === 'error:notconnected')
1305+
dom.throwElementIsNotAttached();
1306+
return result.matches;
13051307
}
13061308

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

18111813
function renderUnexpectedValue(expression: string, received: any): string {
1812-
if (expression === 'to.be.checked')
1813-
return received ? 'checked' : 'unchecked';
1814-
if (expression === 'to.be.unchecked')
1815-
return received ? 'unchecked' : 'checked';
1816-
if (expression === 'to.be.visible')
1817-
return received ? 'visible' : 'hidden';
1818-
if (expression === 'to.be.hidden')
1819-
return received ? 'hidden' : 'visible';
1820-
if (expression === 'to.be.enabled')
1821-
return received ? 'enabled' : 'disabled';
1822-
if (expression === 'to.be.disabled')
1823-
return received ? 'disabled' : 'enabled';
1824-
if (expression === 'to.be.editable')
1825-
return received ? 'editable' : 'readonly';
1826-
if (expression === 'to.be.readonly')
1827-
return received ? 'readonly' : 'editable';
1828-
if (expression === 'to.be.empty')
1829-
return received ? 'empty' : 'not empty';
1830-
if (expression === 'to.be.focused')
1831-
return received ? 'focused' : 'not focused';
18321814
if (expression === 'to.match.aria')
18331815
return received ? received.raw : received;
18341816
return received;

packages/playwright-core/src/server/injected/injectedScript.ts

Lines changed: 74 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -41,8 +41,9 @@ import { parseYamlTemplate } from '@isomorphic/ariaSnapshot';
4141

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

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

4748
export type HitTargetInterceptionResult = {
4849
stop: () => 'done' | { hitTargetDescription: string };
@@ -545,15 +546,15 @@ export class InjectedScript {
545546
if (stableResult === false)
546547
return { missingState: 'stable' };
547548
if (stableResult === 'error:notconnected')
548-
return stableResult;
549+
return 'error:notconnected';
549550
}
550551
for (const state of states) {
551552
if (state !== 'stable') {
552553
const result = this.elementState(node, state);
553-
if (result === false)
554+
if (result.received === 'error:notconnected')
555+
return 'error:notconnected';
556+
if (!result.matches)
554557
return { missingState: state };
555-
if (result === 'error:notconnected')
556-
return result;
557558
}
558559
}
559560
}
@@ -608,38 +609,50 @@ export class InjectedScript {
608609
return result;
609610
}
610611

611-
elementState(node: Node, state: ElementStateWithoutStable): boolean | 'error:notconnected' {
612-
const element = this.retarget(node, ['stable', 'visible', 'hidden'].includes(state) ? 'none' : 'follow-label');
612+
elementState(node: Node, state: ElementStateWithoutStable): ElementStateQueryResult {
613+
const element = this.retarget(node, ['visible', 'hidden'].includes(state) ? 'none' : 'follow-label');
613614
if (!element || !element.isConnected) {
614615
if (state === 'hidden')
615-
return true;
616-
return 'error:notconnected';
616+
return { matches: true, received: 'hidden' };
617+
return { matches: false, received: 'error:notconnected' };
617618
}
618619

619-
if (state === 'visible')
620-
return isElementVisible(element);
621-
if (state === 'hidden')
622-
return !isElementVisible(element);
620+
if (state === 'visible' || state === 'hidden') {
621+
const visible = isElementVisible(element);
622+
return {
623+
matches: state === 'visible' ? visible : !visible,
624+
received: visible ? 'visible' : 'hidden'
625+
};
626+
}
623627

624-
const disabled = getAriaDisabled(element);
625-
if (state === 'disabled')
626-
return disabled;
627-
if (state === 'enabled')
628-
return !disabled;
628+
if (state === 'disabled' || state === 'enabled') {
629+
const disabled = getAriaDisabled(element);
630+
return {
631+
matches: state === 'disabled' ? disabled : !disabled,
632+
received: disabled ? 'disabled' : 'enabled'
633+
};
634+
}
629635

630636
if (state === 'editable') {
637+
const disabled = getAriaDisabled(element);
631638
const readonly = getReadonly(element);
632639
if (readonly === 'error')
633640
throw this.createStacklessError('Element is not an <input>, <textarea>, <select> or [contenteditable] and does not have a role allowing [aria-readonly]');
634-
return !disabled && !readonly;
641+
return {
642+
matches: !disabled && !readonly,
643+
received: disabled ? 'disabled' : readonly ? 'readOnly' : 'editable'
644+
};
635645
}
636646

637-
if (state === 'checked' || state === 'unchecked') {
638-
const need = state === 'checked';
647+
if (state === 'checked' || state === 'unchecked' || state === 'mixed') {
648+
const need = state === 'checked' ? true : state === 'unchecked' ? false : 'mixed';
639649
const checked = getChecked(element, false);
640650
if (checked === 'error')
641651
throw this.createStacklessError('Not a checkbox or radio button');
642-
return need === checked;
652+
return {
653+
matches: need === checked,
654+
received: checked === true ? 'checked' : checked === false ? 'unchecked' : 'mixed',
655+
};
643656
}
644657
throw this.createStacklessError(`Unexpected element state "${state}"`);
645658
}
@@ -1220,44 +1233,60 @@ export class InjectedScript {
12201233

12211234
{
12221235
// Element state / boolean values.
1223-
let elementState: boolean | 'error:notconnected' | 'error:notcheckbox' | undefined;
1236+
let result: ElementStateQueryResult | undefined;
12241237
if (expression === 'to.have.attribute') {
1225-
elementState = element.hasAttribute(options.expressionArg);
1238+
const hasAttribute = element.hasAttribute(options.expressionArg);
1239+
result = {
1240+
matches: hasAttribute,
1241+
received: hasAttribute ? 'attribute present' : 'attribute not present',
1242+
};
12261243
} else if (expression === 'to.be.checked') {
1227-
elementState = this.elementState(element, 'checked');
1244+
result = this.elementState(element, 'checked');
12281245
} else if (expression === 'to.be.unchecked') {
1229-
elementState = this.elementState(element, 'unchecked');
1246+
result = this.elementState(element, 'unchecked');
12301247
} else if (expression === 'to.be.disabled') {
1231-
elementState = this.elementState(element, 'disabled');
1248+
result = this.elementState(element, 'disabled');
12321249
} else if (expression === 'to.be.editable') {
1233-
elementState = this.elementState(element, 'editable');
1250+
result = this.elementState(element, 'editable');
12341251
} else if (expression === 'to.be.readonly') {
1235-
elementState = !this.elementState(element, 'editable');
1252+
result = this.elementState(element, 'editable');
1253+
result.matches = !result.matches;
12361254
} else if (expression === 'to.be.empty') {
1237-
if (element.nodeName === 'INPUT' || element.nodeName === 'TEXTAREA')
1238-
elementState = !(element as HTMLInputElement).value;
1239-
else
1240-
elementState = !element.textContent?.trim();
1255+
if (element.nodeName === 'INPUT' || element.nodeName === 'TEXTAREA') {
1256+
const value = (element as HTMLInputElement).value;
1257+
result = { matches: !value, received: value ? 'notEmpty' : 'empty' };
1258+
} else {
1259+
const text = element.textContent?.trim();
1260+
result = { matches: !text, received: text ? 'notEmpty' : 'empty' };
1261+
}
12411262
} else if (expression === 'to.be.enabled') {
1242-
elementState = this.elementState(element, 'enabled');
1263+
result = this.elementState(element, 'enabled');
12431264
} else if (expression === 'to.be.focused') {
1244-
elementState = this._activelyFocused(element).isFocused;
1265+
const focused = this._activelyFocused(element).isFocused;
1266+
result = {
1267+
matches: focused,
1268+
received: focused ? 'focused' : 'inactive',
1269+
};
12451270
} else if (expression === 'to.be.hidden') {
1246-
elementState = this.elementState(element, 'hidden');
1271+
result = this.elementState(element, 'hidden');
12471272
} else if (expression === 'to.be.visible') {
1248-
elementState = this.elementState(element, 'visible');
1273+
result = this.elementState(element, 'visible');
12491274
} else if (expression === 'to.be.attached') {
1250-
elementState = true;
1275+
result = {
1276+
matches: true,
1277+
received: 'attached',
1278+
};
12511279
} else if (expression === 'to.be.detached') {
1252-
elementState = false;
1280+
result = {
1281+
matches: false,
1282+
received: 'attached',
1283+
};
12531284
}
12541285

1255-
if (elementState !== undefined) {
1256-
if (elementState === 'error:notcheckbox')
1257-
throw this.createStacklessError('Element is not a checkbox');
1258-
if (elementState === 'error:notconnected')
1286+
if (result) {
1287+
if (result.received === 'error:notconnected')
12591288
throw this.createStacklessError('Element is not connected');
1260-
return { received: elementState, matches: elementState };
1289+
return result;
12611290
}
12621291
}
12631292

packages/playwright/src/matchers/matchers.ts

Lines changed: 11 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -42,9 +42,8 @@ export function toBeAttached(
4242
) {
4343
const attached = !options || options.attached === undefined || options.attached;
4444
const expected = attached ? 'attached' : 'detached';
45-
const unexpected = attached ? 'detached' : 'attached';
4645
const arg = attached ? '' : '{ attached: false }';
47-
return toBeTruthy.call(this, 'toBeAttached', locator, 'Locator', expected, unexpected, arg, async (isNot, timeout) => {
46+
return toBeTruthy.call(this, 'toBeAttached', locator, 'Locator', expected, arg, async (isNot, timeout) => {
4847
return await locator._expect(attached ? 'to.be.attached' : 'to.be.detached', { isNot, timeout });
4948
}, options);
5049
}
@@ -56,9 +55,8 @@ export function toBeChecked(
5655
) {
5756
const checked = !options || options.checked === undefined || options.checked;
5857
const expected = checked ? 'checked' : 'unchecked';
59-
const unexpected = checked ? 'unchecked' : 'checked';
6058
const arg = checked ? '' : '{ checked: false }';
61-
return toBeTruthy.call(this, 'toBeChecked', locator, 'Locator', expected, unexpected, arg, async (isNot, timeout) => {
59+
return toBeTruthy.call(this, 'toBeChecked', locator, 'Locator', expected, arg, async (isNot, timeout) => {
6260
return await locator._expect(checked ? 'to.be.checked' : 'to.be.unchecked', { isNot, timeout });
6361
}, options);
6462
}
@@ -68,7 +66,7 @@ export function toBeDisabled(
6866
locator: LocatorEx,
6967
options?: { timeout?: number },
7068
) {
71-
return toBeTruthy.call(this, 'toBeDisabled', locator, 'Locator', 'disabled', 'enabled', '', async (isNot, timeout) => {
69+
return toBeTruthy.call(this, 'toBeDisabled', locator, 'Locator', 'disabled', '', async (isNot, timeout) => {
7270
return await locator._expect('to.be.disabled', { isNot, timeout });
7371
}, options);
7472
}
@@ -80,9 +78,8 @@ export function toBeEditable(
8078
) {
8179
const editable = !options || options.editable === undefined || options.editable;
8280
const expected = editable ? 'editable' : 'readOnly';
83-
const unexpected = editable ? 'readOnly' : 'editable';
8481
const arg = editable ? '' : '{ editable: false }';
85-
return toBeTruthy.call(this, 'toBeEditable', locator, 'Locator', expected, unexpected, arg, async (isNot, timeout) => {
82+
return toBeTruthy.call(this, 'toBeEditable', locator, 'Locator', expected, arg, async (isNot, timeout) => {
8683
return await locator._expect(editable ? 'to.be.editable' : 'to.be.readonly', { isNot, timeout });
8784
}, options);
8885
}
@@ -92,7 +89,7 @@ export function toBeEmpty(
9289
locator: LocatorEx,
9390
options?: { timeout?: number },
9491
) {
95-
return toBeTruthy.call(this, 'toBeEmpty', locator, 'Locator', 'empty', 'notEmpty', '', async (isNot, timeout) => {
92+
return toBeTruthy.call(this, 'toBeEmpty', locator, 'Locator', 'empty', '', async (isNot, timeout) => {
9693
return await locator._expect('to.be.empty', { isNot, timeout });
9794
}, options);
9895
}
@@ -104,9 +101,8 @@ export function toBeEnabled(
104101
) {
105102
const enabled = !options || options.enabled === undefined || options.enabled;
106103
const expected = enabled ? 'enabled' : 'disabled';
107-
const unexpected = enabled ? 'disabled' : 'enabled';
108104
const arg = enabled ? '' : '{ enabled: false }';
109-
return toBeTruthy.call(this, 'toBeEnabled', locator, 'Locator', expected, unexpected, arg, async (isNot, timeout) => {
105+
return toBeTruthy.call(this, 'toBeEnabled', locator, 'Locator', expected, arg, async (isNot, timeout) => {
110106
return await locator._expect(enabled ? 'to.be.enabled' : 'to.be.disabled', { isNot, timeout });
111107
}, options);
112108
}
@@ -116,7 +112,7 @@ export function toBeFocused(
116112
locator: LocatorEx,
117113
options?: { timeout?: number },
118114
) {
119-
return toBeTruthy.call(this, 'toBeFocused', locator, 'Locator', 'focused', 'inactive', '', async (isNot, timeout) => {
115+
return toBeTruthy.call(this, 'toBeFocused', locator, 'Locator', 'focused', '', async (isNot, timeout) => {
120116
return await locator._expect('to.be.focused', { isNot, timeout });
121117
}, options);
122118
}
@@ -126,7 +122,7 @@ export function toBeHidden(
126122
locator: LocatorEx,
127123
options?: { timeout?: number },
128124
) {
129-
return toBeTruthy.call(this, 'toBeHidden', locator, 'Locator', 'hidden', 'visible', '', async (isNot, timeout) => {
125+
return toBeTruthy.call(this, 'toBeHidden', locator, 'Locator', 'hidden', '', async (isNot, timeout) => {
130126
return await locator._expect('to.be.hidden', { isNot, timeout });
131127
}, options);
132128
}
@@ -138,9 +134,8 @@ export function toBeVisible(
138134
) {
139135
const visible = !options || options.visible === undefined || options.visible;
140136
const expected = visible ? 'visible' : 'hidden';
141-
const unexpected = visible ? 'hidden' : 'visible';
142137
const arg = visible ? '' : '{ visible: false }';
143-
return toBeTruthy.call(this, 'toBeVisible', locator, 'Locator', expected, unexpected, arg, async (isNot, timeout) => {
138+
return toBeTruthy.call(this, 'toBeVisible', locator, 'Locator', expected, arg, async (isNot, timeout) => {
144139
return await locator._expect(visible ? 'to.be.visible' : 'to.be.hidden', { isNot, timeout });
145140
}, options);
146141
}
@@ -150,7 +145,7 @@ export function toBeInViewport(
150145
locator: LocatorEx,
151146
options?: { timeout?: number, ratio?: number },
152147
) {
153-
return toBeTruthy.call(this, 'toBeInViewport', locator, 'Locator', 'in viewport', 'outside viewport', '', async (isNot, timeout) => {
148+
return toBeTruthy.call(this, 'toBeInViewport', locator, 'Locator', 'in viewport', '', async (isNot, timeout) => {
154149
return await locator._expect('to.be.in.viewport', { isNot, expectedNumber: options?.ratio, timeout });
155150
}, options);
156151
}
@@ -232,7 +227,7 @@ export function toHaveAttribute(
232227
}
233228
}
234229
if (expected === undefined) {
235-
return toBeTruthy.call(this, 'toHaveAttribute', locator, 'Locator', 'have attribute', 'not have attribute', '', async (isNot, timeout) => {
230+
return toBeTruthy.call(this, 'toHaveAttribute', locator, 'Locator', 'have attribute', '', async (isNot, timeout) => {
236231
return await locator._expect('to.have.attribute', { expressionArg: name, isNot, timeout });
237232
}, options);
238233
}

0 commit comments

Comments
 (0)