feat(expect): add more matchers (#7891)

This commit is contained in:
Pavel Feldman 2021-07-28 15:44:44 -07:00 committed by GitHub
parent 3f0485486d
commit 3187ffdebf
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 372 additions and 71 deletions

View File

@ -15,9 +15,29 @@
*/
import expectLibrary from 'expect';
import { toBeChecked, toBeDisabled, toBeEditable, toBeEmpty, toBeEnabled, toBeFocused, toBeHidden, toBeVisible } from './matchers/toBeTruthy';
import {
toBeChecked,
toBeDisabled,
toBeEditable,
toBeEmpty,
toBeEnabled,
toBeFocused,
toBeHidden,
toBeSelected,
toBeVisible
} from './matchers/toBeTruthy';
import { toHaveLength, toHaveProp } from './matchers/toEqual';
import { toMatchSnapshot } from './matchers/toMatchSnapshot';
import { toContainText, toHaveAttr, toHaveCSS, toHaveData, toHaveId, toHaveText, toHaveValue } from './matchers/toMatchText';
import {
toContainText,
toHaveAttr,
toHaveCSS,
toHaveClass,
toHaveData,
toHaveId,
toHaveText,
toHaveValue
} from './matchers/toMatchText';
import type { Expect } from './types';
export const expect: Expect = expectLibrary as any;
@ -30,12 +50,16 @@ expectLibrary.extend({
toBeEnabled,
toBeFocused,
toBeHidden,
toBeSelected,
toBeVisible,
toContainText,
toHaveAttr,
toHaveCSS,
toHaveClass,
toHaveData,
toHaveId,
toHaveLength,
toHaveProp,
toHaveText,
toHaveValue,
toMatchSnapshot,

View File

@ -16,50 +16,46 @@
import {
matcherHint,
MatcherHintOptions,
printReceived
MatcherHintOptions
} from 'jest-matcher-utils';
import { Locator } from '../../..';
import { currentTestInfo } from '../globals';
import type { Expect } from '../types';
import { monotonicTime, pollUntilDeadline } from '../util';
import { expectLocator, monotonicTime, pollUntilDeadline } from '../util';
async function toBeTruthyImpl(
async function toBeTruthyImpl<T>(
this: ReturnType<Expect['getState']>,
matcherName: string,
query: (timeout: number) => Promise<boolean>,
locator: Locator,
query: (timeout: number) => Promise<T>,
options: { timeout?: number } = {},
) {
const testInfo = currentTestInfo();
if (!testInfo)
throw new Error(`toMatchSnapshot() must be called during the test`);
throw new Error(`${matcherName} must be called during the test`);
expectLocator(locator, matcherName);
const matcherOptions: MatcherHintOptions = {
isNot: this.isNot,
promise: this.promise,
};
let received: boolean;
let received: T;
let pass = false;
const timeout = options.timeout === 0 ? 0 : options.timeout || testInfo.timeout;
const deadline = timeout ? monotonicTime() + timeout : 0;
try {
await pollUntilDeadline(async () => {
const remainingTime = deadline ? deadline - monotonicTime() : 0;
received = await query(remainingTime);
pass = !!received;
return pass === !matcherOptions.isNot;
}, deadline, 100);
} catch (e) {
pass = false;
}
// TODO: interrupt on timeout for nice message.
await pollUntilDeadline(async () => {
const remainingTime = deadline ? deadline - monotonicTime() : 0;
received = await query(remainingTime);
pass = !!received;
return pass === !matcherOptions.isNot;
}, deadline, 100);
const message = () =>
matcherHint(matcherName, undefined, '', matcherOptions) +
'\n\n' +
`Received: ${printReceived(received)}`;
const message = () => {
return matcherHint(matcherName, undefined, '', matcherOptions);
};
return { message, pass };
}
@ -69,7 +65,7 @@ export async function toBeChecked(
locator: Locator,
options?: { timeout?: number },
) {
return toBeTruthyImpl.call(this, 'toBeChecked', async timeout => {
return toBeTruthyImpl.call(this, 'toBeChecked', locator, async timeout => {
return await locator.isChecked({ timeout });
}, options);
}
@ -79,7 +75,7 @@ export async function toBeEditable(
locator: Locator,
options?: { timeout?: number },
) {
return toBeTruthyImpl.call(this, 'toBeEditable', async timeout => {
return toBeTruthyImpl.call(this, 'toBeEditable', locator, async timeout => {
return await locator.isEditable({ timeout });
}, options);
}
@ -89,7 +85,7 @@ export async function toBeEnabled(
locator: Locator,
options?: { timeout?: number },
) {
return toBeTruthyImpl.call(this, 'toBeEnabled', async timeout => {
return toBeTruthyImpl.call(this, 'toBeEnabled', locator, async timeout => {
return await locator.isEnabled({ timeout });
}, options);
}
@ -99,7 +95,7 @@ export async function toBeDisabled(
locator: Locator,
options?: { timeout?: number },
) {
return toBeTruthyImpl.call(this, 'toBeDisabled', async timeout => {
return toBeTruthyImpl.call(this, 'toBeDisabled', locator, async timeout => {
return await locator.isDisabled({ timeout });
}, options);
}
@ -109,7 +105,7 @@ export async function toBeEmpty(
locator: Locator,
options?: { timeout?: number },
) {
return toBeTruthyImpl.call(this, 'toBeEmpty', async timeout => {
return toBeTruthyImpl.call(this, 'toBeEmpty', locator, async timeout => {
return await locator.evaluate(element => {
if (element.nodeName === 'INPUT' || element.nodeName === 'TEXTAREA')
return !(element as HTMLInputElement).value;
@ -123,7 +119,7 @@ export async function toBeHidden(
locator: Locator,
options?: { timeout?: number },
) {
return toBeTruthyImpl.call(this, 'toBeHidden', async timeout => {
return toBeTruthyImpl.call(this, 'toBeHidden', locator, async timeout => {
return await locator.isHidden({ timeout });
}, options);
}
@ -133,7 +129,7 @@ export async function toBeVisible(
locator: Locator,
options?: { timeout?: number },
) {
return toBeTruthyImpl.call(this, 'toBeVisible', async timeout => {
return toBeTruthyImpl.call(this, 'toBeVisible', locator, async timeout => {
return await locator.isVisible({ timeout });
}, options);
}
@ -143,9 +139,21 @@ export async function toBeFocused(
locator: Locator,
options?: { timeout?: number },
) {
return toBeTruthyImpl.call(this, 'toBeFocused', async timeout => {
return toBeTruthyImpl.call(this, 'toBeFocused', locator, async timeout => {
return await locator.evaluate(element => {
return document.activeElement === element;
}, { timeout });
}, options);
}
export async function toBeSelected(
this: ReturnType<Expect['getState']>,
locator: Locator,
options?: { timeout?: number },
) {
return toBeTruthyImpl.call(this, 'toBeSelected', locator, async timeout => {
return await locator.evaluate(element => {
return (element as HTMLOptionElement).selected;
}, { timeout });
}, options);
}

View File

@ -0,0 +1,121 @@
/**
* Copyright Microsoft Corporation. All rights reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { equals } from 'expect/build/jasmineUtils';
import matchers from 'expect/build/matchers';
import {
iterableEquality
} from 'expect/build/utils';
import {
matcherHint, MatcherHintOptions,
printDiffOrStringify,
printExpected,
printReceived,
stringify
} from 'jest-matcher-utils';
import { Locator } from '../../..';
import { currentTestInfo } from '../globals';
import type { Expect } from '../types';
import { expectLocator, monotonicTime, pollUntilDeadline } from '../util';
// Omit colon and one or more spaces, so can call getLabelPrinter.
const EXPECTED_LABEL = 'Expected';
const RECEIVED_LABEL = 'Received';
// The optional property of matcher context is true if undefined.
const isExpand = (expand?: boolean): boolean => expand !== false;
async function toEqualImpl<T>(
this: ReturnType<Expect['getState']>,
matcherName: string,
locator: Locator,
query: (timeout: number) => Promise<T>,
expected: T,
options: { timeout?: number } = {},
) {
const testInfo = currentTestInfo();
if (!testInfo)
throw new Error(`${matcherName} must be called during the test`);
expectLocator(locator, matcherName);
const matcherOptions: MatcherHintOptions = {
comment: 'deep equality',
isNot: this.isNot,
promise: this.promise,
};
let received: T | undefined = undefined;
let pass = false;
const timeout = options.timeout === 0 ? 0 : options.timeout || testInfo.timeout;
const deadline = timeout ? monotonicTime() + timeout : 0;
// TODO: interrupt on timeout for nice message.
await pollUntilDeadline(async () => {
const remainingTime = deadline ? deadline - monotonicTime() : 0;
received = await query(remainingTime);
pass = equals(received, expected, [iterableEquality]);
return pass === !matcherOptions.isNot;
}, deadline, 100);
const message = pass
? () =>
matcherHint(matcherName, undefined, undefined, matcherOptions) +
'\n\n' +
`Expected: not ${printExpected(expected)}\n` +
(stringify(expected) !== stringify(received)
? `Received: ${printReceived(received)}`
: '')
: () =>
matcherHint(matcherName, undefined, undefined, matcherOptions) +
'\n\n' +
printDiffOrStringify(
expected,
received,
EXPECTED_LABEL,
RECEIVED_LABEL,
isExpand(this.expand),
);
// Passing the actual and expected objects so that a custom reporter
// could access them, for example in order to display a custom visual diff,
// or create a different error message
return { actual: received, expected, message, name: matcherName, pass };
}
export async function toHaveLength(
this: ReturnType<Expect['getState']>,
locator: Locator,
expected: number,
options?: { timeout?: number },
) {
if (typeof locator !== 'object' || locator.constructor.name !== 'Locator')
return matchers.toHaveLength.call(this, locator, expected);
return toEqualImpl.call(this, 'toHaveLength', locator, async timeout => {
return await locator.count();
}, expected, { expectedType: 'number', ...options });
}
export async function toHaveProp(
this: ReturnType<Expect['getState']>,
locator: Locator,
name: string,
expected: number,
options?: { timeout?: number },
) {
return toEqualImpl.call(this, 'toHaveProp', locator, async timeout => {
return await locator.evaluate((element, name) => (element as any)[name], name, { timeout });
}, expected, { expectedType: 'number', ...options });
}

View File

@ -31,18 +31,20 @@ import {
import { Locator } from '../../..';
import { currentTestInfo } from '../globals';
import type { Expect } from '../types';
import { monotonicTime, pollUntilDeadline } from '../util';
import { expectLocator, monotonicTime, pollUntilDeadline } from '../util';
async function toMatchTextImpl(
this: ReturnType<Expect['getState']>,
matcherName: string,
locator: Locator,
query: (timeout: number) => Promise<string>,
expected: string | RegExp,
options: { timeout?: number, matchSubstring?: boolean } = {},
) {
const testInfo = currentTestInfo();
if (!testInfo)
throw new Error(`toMatchSnapshot() must be called during the test`);
throw new Error(`${matcherName} must be called during the test`);
expectLocator(locator, matcherName);
const matcherOptions: MatcherHintOptions = {
isNot: this.isNot,
@ -69,22 +71,19 @@ async function toMatchTextImpl(
const timeout = options.timeout === 0 ? 0 : options.timeout || testInfo.timeout;
const deadline = timeout ? monotonicTime() + timeout : 0;
try {
await pollUntilDeadline(async () => {
const remainingTime = deadline ? deadline - monotonicTime() : 0;
received = await query(remainingTime);
if (options.matchSubstring)
pass = received.includes(expected as string);
else if (typeof expected === 'string')
pass = received === expected;
else
pass = expected.test(received);
// TODO: interrupt on timeout for nice message.
await pollUntilDeadline(async () => {
const remainingTime = deadline ? deadline - monotonicTime() : 0;
received = await query(remainingTime);
if (options.matchSubstring)
pass = received.includes(expected as string);
else if (typeof expected === 'string')
pass = received === expected;
else
pass = expected.test(received);
return pass === !matcherOptions.isNot;
}, deadline, 100);
} catch (e) {
pass = false;
}
return pass === !matcherOptions.isNot;
}, deadline, 100);
const stringSubstring = options.matchSubstring ? 'substring' : 'string';
const message = pass
@ -130,7 +129,7 @@ export async function toHaveText(
expected: string | RegExp,
options?: { timeout?: number, useInnerText?: boolean },
) {
return toMatchTextImpl.call(this, 'toHaveText', async timeout => {
return toMatchTextImpl.call(this, 'toHaveText', locator, async timeout => {
if (options?.useInnerText)
return await locator.innerText({ timeout });
return await locator.textContent() || '';
@ -143,7 +142,7 @@ export async function toContainText(
expected: string,
options?: { timeout?: number, useInnerText?: boolean },
) {
return toMatchTextImpl.call(this, 'toContainText', async timeout => {
return toMatchTextImpl.call(this, 'toContainText', locator, async timeout => {
if (options?.useInnerText)
return await locator.innerText({ timeout });
return await locator.textContent() || '';
@ -157,7 +156,7 @@ export async function toHaveAttr(
expected: string | RegExp,
options?: { timeout?: number },
) {
return toMatchTextImpl.call(this, 'toHaveAttr', async timeout => {
return toMatchTextImpl.call(this, 'toHaveAttr', locator, async timeout => {
return await locator.getAttribute(name, { timeout }) || '';
}, expected, options);
}
@ -169,7 +168,7 @@ export async function toHaveData(
expected: string | RegExp,
options?: { timeout?: number },
) {
return toMatchTextImpl.call(this, 'toHaveData', async timeout => {
return toMatchTextImpl.call(this, 'toHaveData', locator, async timeout => {
return await locator.getAttribute('data-' + name, { timeout }) || '';
}, expected, options);
}
@ -181,7 +180,7 @@ export async function toHaveCSS(
expected: string | RegExp,
options?: { timeout?: number },
) {
return toMatchTextImpl.call(this, 'toHaveCSS', async timeout => {
return toMatchTextImpl.call(this, 'toHaveCSS', locator, async timeout => {
return await locator.evaluate(async (element, name) => {
return (window.getComputedStyle(element) as any)[name];
}, name, { timeout });
@ -194,7 +193,7 @@ export async function toHaveId(
expected: string | RegExp,
options?: { timeout?: number },
) {
return toMatchTextImpl.call(this, 'toHaveId', async timeout => {
return toMatchTextImpl.call(this, 'toHaveId', locator, async timeout => {
return await locator.getAttribute('id', { timeout }) || '';
}, expected, options);
}
@ -205,7 +204,17 @@ export async function toHaveValue(
expected: string | RegExp,
options?: { timeout?: number },
) {
return toMatchTextImpl.call(this, 'toHaveValue', async timeout => {
return toMatchTextImpl.call(this, 'toHaveValue', locator, async timeout => {
return await locator.inputValue({ timeout });
}, expected, options);
}
export async function toHaveClass(
this: ReturnType<Expect['getState']>,
locator: Locator,
expected: string,
options?: { timeout?: number },
) {
return toMatchTextImpl.call(this, 'toHaveClass', locator, async timeout => {
return await locator.evaluate(element => element.className, { timeout });
}, expected, { ...options, matchSubstring: true });
}

View File

@ -18,7 +18,7 @@ import util from 'util';
import path from 'path';
import type { TestError, Location } from './types';
import { default as minimatch } from 'minimatch';
import { TimeoutError } from '../utils/errors';
import { errors } from '../..';
export class DeadlineRunner<T> {
private _timer: NodeJS.Timer | undefined;
@ -72,14 +72,19 @@ export async function raceAgainstDeadline<T>(promise: Promise<T>, deadline: numb
export async function pollUntilDeadline(func: () => Promise<boolean>, deadline: number, delay: number): Promise<void> {
while (true) {
if (await func())
return;
const timeUntilDeadline = deadline ? deadline - monotonicTime() : Number.MAX_VALUE;
if (timeUntilDeadline > 0)
await new Promise(f => setTimeout(f, Math.min(timeUntilDeadline, delay)));
else
throw new TimeoutError('Timed out while waiting for condition to be met');
if (timeUntilDeadline <= 0)
break;
try {
if (await func())
return;
} catch (e) {
if (e instanceof errors.TimeoutError)
return;
throw e;
}
await new Promise(f => setTimeout(f, delay));
}
}
@ -181,3 +186,8 @@ export function errorWithFile(file: string, message: string) {
export function errorWithLocation(location: Location, message: string) {
return new Error(`${formatLocation(location)}: ${message}`);
}
export function expectLocator(receiver: any, matcherName: string) {
if (typeof receiver !== 'object' || receiver.constructor.name !== 'Locator')
throw new Error(`${matcherName} can be only used with Locator object`);
}

View File

@ -47,3 +47,15 @@ it('should throw on capture w/ nth()', async ({page}) => {
const e = await page.locator('*css=div >> p').nth(0).click().catch(e => e);
expect(e.message).toContain(`Can't query n-th element`);
});
it('should throw on due to strictness', async ({page}) => {
await page.setContent(`<div>A</div><div>B</div>`);
const e = await page.locator('div').isVisible().catch(e => e);
expect(e.message).toContain(`strict mode violation`);
});
it('should throw on due to strictness 2', async ({page}) => {
await page.setContent(`<select><option>One</option><option>Two</option></select>`);
const e = await page.locator('option').evaluate(e => {}).catch(e => e);
expect(e.message).toContain(`strict mode violation`);
});

View File

@ -0,0 +1,87 @@
/**
* Copyright (c) Microsoft Corporation.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { test, expect, stripAscii } from './playwright-test-fixtures';
test('should support toHaveLength', async ({ runInlineTest }) => {
const result = await runInlineTest({
'a.test.ts': `
const { test } = pwt;
test('pass', async ({ page }) => {
await page.setContent('<select><option>One</option><option>Two</option></select>');
const locator = page.locator('option');
await expect(locator).toHaveLength(2);
await expect([1, 2]).toHaveLength(2);
});
`,
}, { workers: 1 });
expect(result.passed).toBe(1);
expect(result.exitCode).toBe(0);
});
test('should support toHaveProp', async ({ runInlineTest }) => {
const result = await runInlineTest({
'a.test.ts': `
const { test } = pwt;
test('pass', async ({ page }) => {
await page.setContent('<div></div>');
await page.$eval('div', e => e.foo = { a: 1, b: 'string', c: new Date(1627503992000) });
const locator = page.locator('div');
await expect(locator).toHaveProp('foo', { a: 1, b: 'string', c: new Date(1627503992000) });
});
test('fail', async ({ page }) => {
await page.setContent('<div></div>');
await page.$eval('div', e => e.foo = { a: 1, b: 'string', c: new Date(1627503992000) });
const locator = page.locator('div');
await expect(locator).toHaveProp('foo', { a: 1, b: 'string', c: new Date(1627503992001) }, { timeout: 1000 });
});
`,
}, { workers: 1 });
const output = stripAscii(result.output);
expect(output).toContain('- "c"');
expect(result.passed).toBe(1);
expect(result.failed).toBe(1);
expect(result.exitCode).toBe(1);
});
test('should support toHaveClass', async ({ runInlineTest }) => {
const result = await runInlineTest({
'a.test.ts': `
const { test } = pwt;
test('pass', async ({ page }) => {
await page.setContent('<div class="foo bar baz"></div>');
const locator = page.locator('div');
await expect(locator).toHaveClass('foo');
});
test('fail', async ({ page }) => {
await page.setContent('<div class="bar baz"></div>');
const locator = page.locator('div');
await expect(locator).toHaveClass('foo', { timeout: 1000 });
});
`,
}, { workers: 1 });
const output = stripAscii(result.output);
expect(output).toContain('expect(locator).toHaveClass');
expect(output).toContain('Expected substring: \"foo\"');
expect(result.passed).toBe(1);
expect(result.failed).toBe(1);
expect(result.exitCode).toBe(1);
});

View File

@ -36,7 +36,7 @@ test('should support toBeChecked', async ({ runInlineTest }) => {
test('fail', async ({ page }) => {
await page.setContent('<input type=checkbox></input>');
const locator = page.locator('input');
await expect(locator).toBeChecked({ timeout: 100 });
await expect(locator).toBeChecked({ timeout: 1000 });
});
`,
}, { workers: 1 });
@ -72,7 +72,7 @@ test('should support toBeEditable, toBeEnabled, toBeDisabled, toBeEmpty', async
});
test('empty input', async ({ page }) => {
await page.setContent('<input></inpput>');
await page.setContent('<input></input>');
const locator = page.locator('input');
await expect(locator).toBeEmpty();
});
@ -128,7 +128,7 @@ test('should support toBeVisible, toBeHidden', async ({ runInlineTest }) => {
expect(result.exitCode).toBe(0);
});
test('should support toBeFocused', async ({ runInlineTest }) => {
test('should support toBeFocused, toBeSelected', async ({ runInlineTest }) => {
const result = await runInlineTest({
'a.test.ts': `
const { test } = pwt;
@ -139,8 +139,23 @@ test('should support toBeFocused', async ({ runInlineTest }) => {
await locator.focus();
await expect(locator).toBeFocused({ timeout: 1000 });
});
test('selected', async ({ page }) => {
await page.setContent('<select><option>One</option></select>');
const locator = page.locator('option');
await expect(locator).toBeSelected();
});
test('fail on strict option', async ({ page }) => {
await page.setContent('<select><option>One</option><option>Two</option></select>');
const locator = page.locator('option');
await expect(locator).toBeSelected();
});
`,
}, { workers: 1 });
expect(result.passed).toBe(1);
expect(result.exitCode).toBe(0);
const output = stripAscii(result.output);
expect(output).toContain('strict mode violation');
expect(result.passed).toBe(2);
expect(result.failed).toBe(1);
expect(result.exitCode).toBe(1);
});

17
types/testExpect.d.ts vendored
View File

@ -143,7 +143,22 @@ declare global {
* Asserts given DOM is a focused (active) in document.
*/
toBeFocused(options?: { timeout?: number }): Promise<R>;
}
/**
* Asserts given select option is selected
*/
toBeSelected(options?: { timeout?: number }): Promise<R>;
/**
* Asserts JavaScript object that corresponds to the Node has a property with given value.
*/
toHaveProp(name: string, value: any, options?: { timeout?: number }): Promise<R>;
/**
* Asserts that DOM node has a given CSS class.
*/
toHaveClass(className: string, options?: { timeout?: number }): Promise<R>;
}
}
}