chore: include actual value in the elementState (#34245)
This commit is contained in:
parent
0008816ee3
commit
0d34369fc0
|
@ -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)
|
||||
|
@ -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.
|
||||
}
|
||||
|
|
|
@ -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> {
|
||||
|
@ -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))
|
||||
|
@ -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;
|
||||
|
|
|
@ -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 };
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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}"`);
|
||||
}
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
@ -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);
|
||||
}
|
||||
|
@ -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);
|
||||
}
|
||||
|
@ -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);
|
||||
}
|
||||
|
@ -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);
|
||||
}
|
||||
|
@ -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);
|
||||
}
|
||||
|
@ -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);
|
||||
}
|
||||
|
@ -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);
|
||||
}
|
||||
|
@ -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);
|
||||
}
|
||||
|
@ -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);
|
||||
}
|
||||
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -26,7 +26,6 @@ export async function toBeTruthy(
|
|||
receiver: Locator,
|
||||
receiverType: string,
|
||||
expected: string,
|
||||
unexpected: string,
|
||||
arg: string,
|
||||
query: (isNot: boolean, timeout: number) => Promise<{ matches: boolean, log?: string[], received?: any, timedOut?: boolean }>,
|
||||
options: { timeout?: number } = {},
|
||||
|
@ -50,7 +49,6 @@ export async function toBeTruthy(
|
|||
}
|
||||
|
||||
const notFound = received === kNoElementsFoundError ? received : undefined;
|
||||
const actual = pass ? expected : unexpected;
|
||||
let printedReceived: string | undefined;
|
||||
let printedExpected: string | undefined;
|
||||
if (pass) {
|
||||
|
@ -58,7 +56,7 @@ export async function toBeTruthy(
|
|||
printedReceived = `Received: ${notFound ? kNoElementsFoundError : expected}`;
|
||||
} else {
|
||||
printedExpected = `Expected: ${expected}`;
|
||||
printedReceived = `Received: ${notFound ? kNoElementsFoundError : unexpected}`;
|
||||
printedReceived = `Received: ${notFound ? kNoElementsFoundError : received}`;
|
||||
}
|
||||
const message = () => {
|
||||
const header = matcherHint(this, receiver, matcherName, 'locator', arg, matcherOptions, timedOut ? timeout : undefined);
|
||||
|
@ -68,7 +66,7 @@ export async function toBeTruthy(
|
|||
return {
|
||||
message,
|
||||
pass,
|
||||
actual,
|
||||
actual: received,
|
||||
name: matcherName,
|
||||
expected,
|
||||
log,
|
||||
|
|
|
@ -73,7 +73,7 @@ test('toBeTruthy-based assertions should have matcher result', async ({ page })
|
|||
const e = await expect(page.locator('#node2')).toBeVisible({ timeout: 1 }).catch(e => e);
|
||||
e.matcherResult.message = stripAnsi(e.matcherResult.message);
|
||||
expect.soft(e.matcherResult).toEqual({
|
||||
actual: 'hidden',
|
||||
actual: '<element(s) not found>',
|
||||
expected: 'visible',
|
||||
message: expect.stringContaining(`Timed out 1ms waiting for expect(locator).toBeVisible()`),
|
||||
name: 'toBeVisible',
|
||||
|
|
Loading…
Reference in New Issue