Browse Source

feat(expect): implement toMatchText (#7871)

pull/7875/head
Pavel Feldman 2 months ago
committed by GitHub
parent
commit
b8dc0b9156
No known key found for this signature in database GPG Key ID: 4AEE18F83AFDEB23
  1. 22
      docs/src/api/class-locator.md
  2. 9
      src/client/locator.ts
  3. 47
      src/test/expect.ts
  4. 4
      src/test/matchers/golden.ts
  5. 56
      src/test/matchers/toMatchSnapshot.ts
  6. 140
      src/test/matchers/toMatchText.ts
  7. 15
      src/test/util.ts
  8. 22
      tests/page/locator-misc-2.spec.ts
  9. 110
      tests/playwright-test/playwright.expect.text.spec.ts
  10. 12
      types/testExpect.d.ts
  11. 34
      types/types.d.ts

22
docs/src/api/class-locator.md

@ -65,7 +65,7 @@ element.click()
```
```csharp
var element = page.Finder("text=Submit");
var element = page.Locator("text=Submit");
await element.HoverAsync();
await element.ClickAsync();
```
@ -332,7 +332,7 @@ assert tweets.evaluate("node => node.innerText") == "10 retweets"
```
```csharp
var tweets = page.Finder(".tweet .retweets");
var tweets = page.Locator(".tweet .retweets");
Assert.Equals("10 retweets", await tweets.EvaluateAsync("node => node.innerText"));
```
@ -817,7 +817,7 @@ element.press("Enter")
```
```csharp
var element = page.Finder("input");
var element = page.Locator("input");
await element.TypeAsync("some text");
await element.PressAsync("Enter");
```
@ -856,19 +856,3 @@ When all steps combined have not finished during the specified [`option: timeout
### option: Locator.uncheck.noWaitAfter = %%-input-no-wait-after-%%
### option: Locator.uncheck.timeout = %%-input-timeout-%%
### option: Locator.uncheck.trial = %%-input-trial-%%
## async method: Locator.waitFor
- returns: <[null]|[ElementHandle]<[HTMLElement]|[SVGElement]>>
Returns when element specified by selector satisfies [`option: state`] option. Returns `null` if waiting for `hidden` or `detached`.
Wait for the element to satisfy [`option: state`] option (either appear/disappear from dom, or become
visible/hidden). If at the moment of calling the method it already satisfies the condition, the method
will return immediately. If the selector doesn't satisfy the condition for the [`option: timeout`] milliseconds, the
function will throw.
This method works across navigations.
### option: Locator.waitFor.state = %%-wait-for-selector-state-%%
### option: Locator.waitFor.timeout = %%-input-timeout-%%

9
src/client/locator.ts

@ -22,14 +22,17 @@ import { monotonicTime } from '../utils/utils';
import { ElementHandle } from './elementHandle';
import { Frame } from './frame';
import { FilePayload, Rect, SelectOption, SelectOptionOptions, TimeoutOptions } from './types';
import { TimeoutSettings } from '../utils/timeoutSettings';
export class Locator implements api.Locator {
private _frame: Frame;
private _selector: string;
private _visibleSelector: string;
private _timeoutSettings: TimeoutSettings;
constructor(frame: Frame, selector: string) {
this._frame = frame;
this._timeoutSettings = this._frame.page()._timeoutSettings;
this._selector = selector;
this._visibleSelector = selector + ' >> _visible=true';
}
@ -202,12 +205,6 @@ export class Locator implements api.Locator {
return this._frame.uncheck(this._visibleSelector, { strict: true, ...options });
}
waitFor(options: channels.FrameWaitForSelectorOptions & { state: 'attached' | 'visible' }): Promise<ElementHandle<SVGElement | HTMLElement>>;
waitFor(options?: channels.FrameWaitForSelectorOptions): Promise<ElementHandle<SVGElement | HTMLElement> | null>;
async waitFor(options?: channels.FrameWaitForSelectorOptions): Promise<ElementHandle<SVGElement | HTMLElement> | null> {
return this._frame.waitForSelector(this._visibleSelector, { strict: true, ...options });
}
[(util.inspect as any).custom]() {
return this.toString();
}

47
src/test/expect.ts

@ -16,46 +16,9 @@
import type { Expect } from './types';
import expectLibrary from 'expect';
import { currentTestInfo } from './globals';
import { compare } from './golden';
import { toMatchSnapshot } from './matchers/toMatchSnapshot';
import { toMatchText, toHaveText } from './matchers/toMatchText';
export const expect: Expect = expectLibrary;
function toMatchSnapshot(this: ReturnType<Expect['getState']>, received: Buffer | string, nameOrOptions: string | { name: string, threshold?: number }, optOptions: { threshold?: number } = {}) {
let options: { name: string, threshold?: number };
const testInfo = currentTestInfo();
if (!testInfo)
throw new Error(`toMatchSnapshot() must be called during the test`);
if (typeof nameOrOptions === 'string')
options = { name: nameOrOptions, ...optOptions };
else
options = { ...nameOrOptions };
if (!options.name)
throw new Error(`toMatchSnapshot() requires a "name" parameter`);
const projectThreshold = testInfo.project.expect?.toMatchSnapshot?.threshold;
if (options.threshold === undefined && projectThreshold !== undefined)
options.threshold = projectThreshold;
const withNegateComparison = this.isNot;
const { pass, message, expectedPath, actualPath, diffPath, mimeType } = compare(
received,
options.name,
testInfo.snapshotPath,
testInfo.outputPath,
testInfo.config.updateSnapshots,
withNegateComparison,
options
);
const contentType = mimeType || 'application/octet-stream';
if (expectedPath)
testInfo.attachments.push({ name: 'expected', contentType, path: expectedPath });
if (actualPath)
testInfo.attachments.push({ name: 'actual', contentType, path: actualPath });
if (diffPath)
testInfo.attachments.push({ name: 'diff', contentType, path: diffPath });
return { pass, message: () => message };
}
expectLibrary.extend({ toMatchSnapshot });
expectLibrary.setState({ expand: false });
export const expect: Expect = expectLibrary as any;
expectLibrary.setState({ expand: false });
expectLibrary.extend({ toMatchSnapshot, toMatchText, toHaveText });

4
src/test/golden.ts → src/test/matchers/golden.ts

@ -21,8 +21,8 @@ import fs from 'fs';
import path from 'path';
import jpeg from 'jpeg-js';
import pixelmatch from 'pixelmatch';
import { diff_match_patch, DIFF_INSERT, DIFF_DELETE, DIFF_EQUAL } from '../third_party/diff_match_patch';
import { UpdateSnapshots } from './types';
import { diff_match_patch, DIFF_INSERT, DIFF_DELETE, DIFF_EQUAL } from '../../third_party/diff_match_patch';
import { UpdateSnapshots } from '../types';
// Note: we require the pngjs version of pixelmatch to avoid version mismatches.
const { PNG } = require(require.resolve('pngjs', { paths: [require.resolve('pixelmatch')] }));

56
src/test/matchers/toMatchSnapshot.ts

@ -0,0 +1,56 @@
/**
* 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 type { Expect } from '../types';
import { currentTestInfo } from '../globals';
import { compare } from './golden';
import { SyncExpectationResult } from 'expect/build/types';
export function toMatchSnapshot(this: ReturnType<Expect['getState']>, received: Buffer | string, nameOrOptions: string | { name: string, threshold?: number }, optOptions: { threshold?: number } = {}): SyncExpectationResult {
let options: { name: string, threshold?: number };
const testInfo = currentTestInfo();
if (!testInfo)
throw new Error(`toMatchSnapshot() must be called during the test`);
if (typeof nameOrOptions === 'string')
options = { name: nameOrOptions, ...optOptions };
else
options = { ...nameOrOptions };
if (!options.name)
throw new Error(`toMatchSnapshot() requires a "name" parameter`);
const projectThreshold = testInfo.project.expect?.toMatchSnapshot?.threshold;
if (options.threshold === undefined && projectThreshold !== undefined)
options.threshold = projectThreshold;
const withNegateComparison = this.isNot;
const { pass, message, expectedPath, actualPath, diffPath, mimeType } = compare(
received,
options.name,
testInfo.snapshotPath,
testInfo.outputPath,
testInfo.config.updateSnapshots,
withNegateComparison,
options
);
const contentType = mimeType || 'application/octet-stream';
if (expectedPath)
testInfo.attachments.push({ name: 'expected', contentType, path: expectedPath });
if (actualPath)
testInfo.attachments.push({ name: 'actual', contentType, path: actualPath });
if (diffPath)
testInfo.attachments.push({ name: 'diff', contentType, path: diffPath });
return { pass, message: () => message || '' };
}

140
src/test/matchers/toMatchText.ts

@ -0,0 +1,140 @@
/**
* 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 {
printReceivedStringContainExpectedResult,
printReceivedStringContainExpectedSubstring
} from 'expect/build/print';
import {
EXPECTED_COLOR,
getLabelPrinter,
matcherErrorMessage,
matcherHint, MatcherHintOptions,
printExpected,
printReceived,
printWithType,
} from 'jest-matcher-utils';
import { Locator } from '../../..';
import { currentTestInfo } from '../globals';
import type { Expect } from '../types';
import { monotonicTime, pollUntilDeadline } from '../util';
async function toMatchTextImpl(
this: ReturnType<Expect['getState']>,
locator: Locator,
expected: string | RegExp,
exactMatch: boolean,
options: { timeout?: number, useInnerText?: boolean } = {},
) {
const testInfo = currentTestInfo();
if (!testInfo)
throw new Error(`toMatchSnapshot() must be called during the test`);
const matcherName = exactMatch ? 'toHaveText' : 'toMatchText';
const matcherOptions: MatcherHintOptions = {
isNot: this.isNot,
promise: this.promise,
};
if (
!(typeof expected === 'string') &&
!(expected && typeof expected.test === 'function')
) {
throw new Error(
matcherErrorMessage(
matcherHint(matcherName, undefined, undefined, matcherOptions),
`${EXPECTED_COLOR(
'expected',
)} value must be a string or regular expression`,
printWithType('Expected', expected, printExpected),
),
);
}
let received: string;
let pass = false;
const timeout = options.timeout === 0 ? 0 : options.timeout || testInfo.timeout;
const deadline = timeout ? monotonicTime() + timeout : 0;
try {
await pollUntilDeadline(async () => {
received = options?.useInnerText ? await locator.innerText() : await locator.textContent() || '';
if (exactMatch)
pass = expected === received;
else
pass = typeof expected === 'string' ? received.includes(expected) : new RegExp(expected).test(received);
return pass === !matcherOptions.isNot;
}, deadline, 100);
} catch (e) {
pass = false;
}
const stringSubstring = exactMatch ? 'string' : 'substring';
const message = pass
? () =>
typeof expected === 'string'
? matcherHint(matcherName, undefined, undefined, matcherOptions) +
'\n\n' +
`Expected ${stringSubstring}: not ${printExpected(expected)}\n` +
`Received string: ${printReceivedStringContainExpectedSubstring(
received,
received.indexOf(expected),
expected.length,
)}`
: matcherHint(matcherName, undefined, undefined, matcherOptions) +
'\n\n' +
`Expected pattern: not ${printExpected(expected)}\n` +
`Received string: ${printReceivedStringContainExpectedResult(
received,
typeof expected.exec === 'function'
? expected.exec(received)
: null,
)}`
: () => {
const labelExpected = `Expected ${typeof expected === 'string' ? stringSubstring : 'pattern'
}`;
const labelReceived = 'Received string';
const printLabel = getLabelPrinter(labelExpected, labelReceived);
return (
matcherHint(matcherName, undefined, undefined, matcherOptions) +
'\n\n' +
`${printLabel(labelExpected)}${printExpected(expected)}\n` +
`${printLabel(labelReceived)}${printReceived(received)}`
);
};
return { message, pass };
}
export async function toMatchText(
this: ReturnType<Expect['getState']>,
locator: Locator,
expected: string | RegExp,
options?: { timeout?: number, useInnerText?: boolean },
) {
return toMatchTextImpl.call(this, locator, expected, false, options);
}
export async function toHaveText(
this: ReturnType<Expect['getState']>,
locator: Locator,
expected: string,
options?: { timeout?: number, useInnerText?: boolean },
) {
return toMatchTextImpl.call(this, locator, expected, true, options);
}

15
src/test/util.ts

@ -18,6 +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';
export class DeadlineRunner<T> {
private _timer: NodeJS.Timer | undefined;
@ -69,6 +70,20 @@ export async function raceAgainstDeadline<T>(promise: Promise<T>, deadline: numb
return (new DeadlineRunner(promise, deadline)).result;
}
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');
}
}
export function serializeError(error: Error | any): TestError {
if (error instanceof Error) {
return {

22
tests/page/locator-misc-2.spec.ts

@ -61,28 +61,6 @@ it('should type', async ({ page }) => {
expect(await page.$eval('input', input => input.value)).toBe('hello');
});
it('should wait for visible', async ({ page }) => {
async function giveItAChanceToResolve() {
for (let i = 0; i < 5; i++)
await page.evaluate(() => new Promise(f => requestAnimationFrame(() => requestAnimationFrame(f))));
}
await page.setContent(`<div id='div' style='display:none'>content</div>`);
const div = page.locator('div');
let done = false;
const promise = div.waitFor({ state: 'visible' }).then(() => done = true);
await giveItAChanceToResolve();
expect(done).toBe(false);
await page.evaluate(() => (window as any).div.style.display = 'block');
await promise;
});
it('should wait for already visible', async ({ page }) => {
await page.setContent(`<div>content</div>`);
const div = page.locator('div');
await div.waitFor({ state: 'visible' });
});
it('should take screenshot', async ({ page, server, browserName, headless, isAndroid }) => {
it.skip(browserName === 'firefox' && !headless);
it.skip(isAndroid, 'Different dpr. Remove after using 1x scale for screenshots.');

110
tests/playwright-test/playwright.expect.text.spec.ts

@ -0,0 +1,110 @@
/**
* 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 toMatchText', async ({ runInlineTest }) => {
const result = await runInlineTest({
'a.test.ts': `
const { test } = pwt;
test('pass', async ({ page }) => {
await page.setContent('<div id=node>Text content</div>');
const handle = page.locator('#node');
await expect(handle).toMatchText(/Text/);
});
test('fail', async ({ page }) => {
await page.setContent('<div id=node>Text content</div>');
const handle = page.locator('#node');
await expect(handle).toMatchText(/Text 2/, { timeout: 100 });
});
`,
}, { workers: 1 });
const output = stripAscii(result.output);
expect(output).toContain('Error: expect(received).toMatchText(expected)');
expect(output).toContain('Expected pattern: /Text 2/');
expect(output).toContain('Received string: "Text content"');
expect(output).toContain('expect(handle).toMatchText');
expect(result.passed).toBe(1);
expect(result.failed).toBe(1);
expect(result.exitCode).toBe(1);
});
test('should support toHaveText', async ({ runInlineTest }) => {
const result = await runInlineTest({
'a.test.ts': `
const { test } = pwt;
test('pass', async ({ page }) => {
await page.setContent('<div id=node>Text content</div>');
const handle = page.locator('#node');
await expect(handle).toHaveText('Text content');
});
test('fail', async ({ page }) => {
await page.setContent('<div id=node>Text content</div>');
const handle = page.locator('#node');
await expect(handle).toHaveText('Text', { timeout: 100 });
});
`,
}, { workers: 1 });
const output = stripAscii(result.output);
expect(output).toContain('Error: expect(received).toHaveText(expected)');
expect(output).toContain('Expected string: "Text"');
expect(output).toContain('Received string: "Text content"');
expect(output).toContain('expect(handle).toHaveText');
expect(result.passed).toBe(1);
expect(result.failed).toBe(1);
expect(result.exitCode).toBe(1);
});
test('should support toMatchText eventually', async ({ runInlineTest }) => {
const result = await runInlineTest({
'a.test.ts': `
const { test } = pwt;
test('pass eventually', async ({ page }) => {
await page.setContent('<div id=node>Text content</div>');
const handle = page.locator('#node');
await Promise.all([
expect(handle).toMatchText(/Text 2/),
page.waitForTimeout(1000).then(() => handle.evaluate(element => element.textContent = 'Text 2 content')),
]);
});
`,
}, { workers: 1 });
expect(result.passed).toBe(1);
expect(result.failed).toBe(0);
expect(result.exitCode).toBe(0);
});
test('should support toMatchText with innerText', async ({ runInlineTest }) => {
const result = await runInlineTest({
'a.test.ts': `
const { test } = pwt;
test('pass', async ({ page }) => {
await page.setContent('<div id=node>Text content</div>');
const handle = page.locator('#node');
await expect(handle).toHaveText('Text content', { useInnerText: true });
});
`,
}, { workers: 1 });
expect(result.passed).toBe(1);
expect(result.exitCode).toBe(0);
});

12
types/testExpect.d.ts

@ -35,7 +35,7 @@ type OverriddenExpectProperties =
'rejects' |
'toMatchInlineSnapshot' |
'toThrowErrorMatchingInlineSnapshot' |
'toMatchSnapshot' |
'toMatchSnapshot' |
'toThrowErrorMatchingSnapshot';
declare global {
@ -68,6 +68,16 @@ declare global {
toMatchSnapshot(name: string, options?: {
threshold?: number
}): R;
/**
* Asserts element's exact text content.
*/
toHaveText(expected: string, options?: { timeout?: number, useInnerText?: boolean }): Promise<R>;
/**
* Asserts element's text content matches given pattern or contains given substring.
*/
toMatchText(expected: string | RegExp, options?: { timeout?: number, useInnerText?: boolean }): Promise<R>;
}
}
}

34
types/types.d.ts

@ -8036,39 +8036,7 @@ export interface Locator {
* `false`. Useful to wait until the element is ready for the action without performing it.
*/
trial?: boolean;
}): Promise<void>;
/**
* Returns when element specified by selector satisfies `state` option. Returns `null` if waiting for `hidden` or
* `detached`.
*
* Wait for the element to satisfy `state` option (either appear/disappear from dom, or become visible/hidden). If at the
* moment of calling the method it already satisfies the condition, the method will return immediately. If the selector
* doesn't satisfy the condition for the `timeout` milliseconds, the function will throw.
*
* This method works across navigations.
* @param options
*/
waitFor(options?: {
/**
* Defaults to `'visible'`. Can be either:
* - `'attached'` - wait for element to be present in DOM.
* - `'detached'` - wait for element to not be present in DOM.
* - `'visible'` - wait for element to have non-empty bounding box and no `visibility:hidden`. Note that element without
* any content or with `display:none` has an empty bounding box and is not considered visible.
* - `'hidden'` - wait for element to be either detached from DOM, or have an empty bounding box or `visibility:hidden`.
* This is opposite to the `'visible'` option.
*/
state?: "attached"|"detached"|"visible"|"hidden";
/**
* Maximum time in milliseconds, defaults to 30 seconds, pass `0` to disable timeout. The default value can be changed by
* using the
* [browserContext.setDefaultTimeout(timeout)](https://playwright.dev/docs/api/class-browsercontext#browser-context-set-default-timeout)
* or [page.setDefaultTimeout(timeout)](https://playwright.dev/docs/api/class-page#page-set-default-timeout) methods.
*/
timeout?: number;
}): Promise<null|ElementHandle<HTMLElement|SVGElement>>;}
}): Promise<void>;}
/**
* BrowserType provides methods to launch a specific browser instance or connect to an existing one. The following is a

Loading…
Cancel
Save