feat(expect): toContainText(array) (#9160)

This matches when each expected item from the array
is matched to one of the resolved elements, in order.
Note this performs both "sub-array" and "substring" matching.

Drive-by: documentation fixes.
Drive-by: added "selector resolved to 3 elements" log line
when expecting arrays.
This commit is contained in:
Dmitry Gozman 2021-09-27 11:14:35 -07:00 committed by GitHub
parent cd22072685
commit 8dc8777ab4
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
8 changed files with 112 additions and 31 deletions

View file

@ -118,17 +118,25 @@ const locator = page.locator('.my-element');
await expect(locator).toBeVisible();
```
## expect(locator).toContainText(text, options?)
- `text`: <[string]> Text to look for inside the element
## expect(locator).toContainText(expected, options?)
- `expected`: <[string] | [RegExp] | [Array]<[string]|[RegExp]>>
- `options`
- `timeout`: <[number]> Time to wait for, defaults to `timeout` in [`property: TestProject.expect`].
- `timeout`: <[number]> Time to retry assertion for, defaults to `timeout` in [`property: TestProject.expect`].
- `useInnerText`: <[boolean]> Whether to use `element.innerText` instead of `element.textContent` when retrieving DOM node text.
Ensures [Locator] points to a selected option.
Ensures [Locator] points to an element that contains the given text. You can use regular expressions for the value as well.
```js
const locator = page.locator('.title');
await expect(locator).toContainText('substring');
await expect(locator).toContainText(/\d messages/);
```
Note that if array is passed as an expected value, entire lists can be asserted:
```js
const locator = page.locator('list > .list-item');
await expect(locator).toContainText(['Text 1', 'Text 4', 'Text 5']);
```
## expect(locator).toHaveAttribute(name, value)
@ -171,7 +179,7 @@ await expect(locator).toHaveClass(['component', 'component selected', 'component
Ensures [Locator] resolves to an exact number of DOM nodes.
```js
const list = page.locator('list > #component');
const list = page.locator('list > .component');
await expect(list).toHaveCount(3);
```
@ -181,7 +189,7 @@ await expect(list).toHaveCount(3);
- `options`
- `timeout`: <[number]> Time to retry assertion for, defaults to `timeout` in [`property: TestProject.expect`].
Ensures [Locator] resolves to an element with the given computed CSS style
Ensures [Locator] resolves to an element with the given computed CSS style.
```js
const locator = page.locator('button');
@ -224,13 +232,14 @@ Ensures [Locator] points to an element with the given text. You can use regular
```js
const locator = page.locator('.title');
await expect(locator).toHaveText(/Welcome, Test User/);
await expect(locator).toHaveText(/Welcome, .*/);
```
Note that if array is passed as an expected value, entire lists can be asserted:
```js
const locator = page.locator('list > #component');
const locator = page.locator('list > .component');
await expect(locator).toHaveText(['Text 1', 'Text 2', 'Text 3']);
```

View file

@ -1256,6 +1256,7 @@ export class Frame extends SdkObject {
return poller((progress, continuePolling) => {
if (querySelectorAll) {
const elements = injected.querySelectorAll(info.parsed, document);
progress.logRepeating(` selector resolved to ${elements.length} element${elements.length === 1 ? '' : 's'}`);
return callback(progress, elements[0], taskData as T, elements, continuePolling);
}

View file

@ -882,20 +882,26 @@ export class InjectedScript {
{
// List of values.
let received: string[] | undefined;
if (expression === 'to.have.text.array')
if (expression === 'to.have.text.array' || expression === 'to.contain.text.array')
received = elements.map(e => options.useInnerText ? (e as HTMLElement).innerText : e.textContent || '');
else if (expression === 'to.have.class.array')
received = elements.map(e => e.className);
if (received && options.expectedText) {
if (received.length !== options.expectedText.length) {
// "To match an array" is "to contain an array" + "equal length"
const lengthShouldMatch = expression !== 'to.contain.text.array';
if (received.length !== options.expectedText.length && lengthShouldMatch) {
progress.setIntermediateResult(received);
return continuePolling;
}
// Each matcher should get a "received" that matches it, in order.
let i = 0;
const matchers = options.expectedText.map(e => new ExpectedTextMatcher(e));
for (let i = 0; i < received.length; ++i) {
if (matchers[i].matches(received[i]) === options.isNot) {
for (const matcher of matchers) {
while (i < received.length && matcher.matches(received[i]) === options.isNot)
i++;
if (i === received.length) {
progress.setIntermediateResult(received);
return continuePolling;
}

View file

@ -109,13 +109,20 @@ export function toBeVisible(
export function toContainText(
this: ReturnType<Expect['getState']>,
locator: LocatorEx,
expected: string,
expected: string | RegExp | (string | RegExp)[],
options?: { timeout?: number, useInnerText?: boolean },
) {
return toMatchText.call(this, 'toContainText', locator, 'Locator', async (isNot, timeout) => {
const expectedText = toExpectedTextValues([expected], { matchSubstring: true, normalizeWhiteSpace: true });
return await locator._expect('to.have.text', { expectedText, isNot, useInnerText: options?.useInnerText, timeout });
}, expected, options);
if (Array.isArray(expected)) {
return toEqual.call(this, 'toContainText', locator, 'Locator', async (isNot, timeout) => {
const expectedText = toExpectedTextValues(expected, { matchSubstring: true, normalizeWhiteSpace: true });
return await locator._expect('to.contain.text.array', { expectedText, isNot, useInnerText: options?.useInnerText, timeout });
}, expected, { ...options, contains: true });
} else {
return toMatchText.call(this, 'toContainText', locator, 'Locator', async (isNot, timeout) => {
const expectedText = toExpectedTextValues([expected], { matchSubstring: true, normalizeWhiteSpace: true });
return await locator._expect('to.have.text', { expectedText, isNot, useInnerText: options?.useInnerText, timeout });
}, expected, options);
}
}
export function toHaveAttribute(

View file

@ -33,7 +33,7 @@ export async function toEqual<T>(
receiverType: string,
query: (isNot: boolean, timeout: number) => Promise<{ pass: boolean, received?: any, log?: string[] }>,
expected: T,
options: { timeout?: number } = {},
options: { timeout?: number, contains?: boolean } = {},
) {
const testInfo = currentTestInfo();
if (!testInfo)
@ -41,7 +41,7 @@ export async function toEqual<T>(
expectType(receiver, receiverType, matcherName);
const matcherOptions = {
comment: 'deep equality',
comment: options.contains ? '' : 'deep equality',
isNot: this.isNot,
promise: this.promise,
};

View file

@ -47,6 +47,37 @@ test('should support toHaveText w/ regex', async ({ runInlineTest }) => {
expect(result.exitCode).toBe(1);
});
test('should support toContainText w/ regex', 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 locator = page.locator('#node');
await expect(locator).toContainText(/ex/);
// Should not normalize whitespace.
await expect(locator).toContainText(/ext cont/);
});
test('fail', async ({ page }) => {
await page.setContent('<div id=node>Text content</div>');
const locator = page.locator('#node');
await expect(locator).toContainText(/ex2/, { timeout: 100 });
});
`,
}, { workers: 1 });
const output = stripAscii(result.output);
expect(output).toContain('Error: expect(received).toContainText(expected)');
expect(output).toContain('Expected pattern: /ex2/');
expect(output).toContain('Received string: "Text content"');
expect(output).toContain('expect(locator).toContainText');
expect(result.passed).toBe(1);
expect(result.failed).toBe(1);
expect(result.exitCode).toBe(1);
});
test('should support toHaveText w/ text', async ({ runInlineTest }) => {
const result = await runInlineTest({
'a.test.ts': `
@ -64,7 +95,7 @@ test('should support toHaveText w/ text', async ({ runInlineTest }) => {
const locator = page.locator('#node');
await expect(locator).toContainText('Text');
// Should normalize whitespace.
await expect(locator).toContainText(' Text content\\n ');
await expect(locator).toContainText(' ext cont\\n ');
});
test('fail', async ({ page }) => {
@ -127,14 +158,43 @@ test('should support toHaveText w/ array', async ({ runInlineTest }) => {
test('fail', async ({ page }) => {
await page.setContent('<div>Text 1</div><div>Text 3</div>');
const locator = page.locator('div');
await expect(locator).toHaveText(['Text 1', /Text \\d+a/], { timeout: 1000 });
await expect(locator).toHaveText(['Text 1', /Text \\d/, 'Extra'], { timeout: 1000 });
});
`,
}, { workers: 1 });
const output = stripAscii(result.output);
expect(output).toContain('Error: expect(received).toHaveText(expected) // deep equality');
expect(output).toContain('await expect(locator).toHaveText');
expect(output).toContain('- /Text \\d+a/');
expect(output).toContain('- "Extra"');
expect(output).toContain('waiting for selector "div"');
expect(output).toContain('selector resolved to 2 elements');
expect(result.passed).toBe(1);
expect(result.failed).toBe(1);
expect(result.exitCode).toBe(1);
});
test('should support toContainText w/ array', async ({ runInlineTest }) => {
const result = await runInlineTest({
'a.test.ts': `
const { test } = pwt;
test('pass', async ({ page }) => {
await page.setContent('<div>Text \\n1</div><div>Text2</div><div>Text3</div>');
const locator = page.locator('div');
await expect(locator).toContainText(['ext 1', /ext3/]);
});
test('fail', async ({ page }) => {
await page.setContent('<div>Text 1</div><div>Text 3</div>');
const locator = page.locator('div');
await expect(locator).toContainText(['Text 2'], { timeout: 1000 });
});
`,
}, { workers: 1 });
const output = stripAscii(result.output);
expect(output).toContain('Error: expect(received).toContainText(expected)');
expect(output).toContain('await expect(locator).toContainText');
expect(output).toContain('- "Text 2"');
expect(result.passed).toBe(1);
expect(result.failed).toBe(1);
expect(result.exitCode).toBe(1);

View file

@ -232,18 +232,16 @@ test('should have correct stack trace', async ({ showTraceViewer }) => {
await traceViewer.selectAction('page.click');
await traceViewer.showSourceTab();
const stack1 = (await traceViewer.stackFrames.allInnerTexts()).map(s => s.replace(/\s+/g, ' ').replace(/:[0-9]+/g, ':XXX'));
expect(stack1.slice(0, 2)).toEqual([
'doClick trace-viewer.spec.ts :XXX',
'recordTrace trace-viewer.spec.ts :XXX',
]);
await expect(traceViewer.stackFrames).toContainText([
/doClick\s+trace-viewer.spec.ts\s+:\d+/,
/recordTrace\s+trace-viewer.spec.ts\s+:\d+/,
], { useInnerText: true });
await traceViewer.selectAction('page.hover');
await traceViewer.showSourceTab();
const stack2 = (await traceViewer.stackFrames.allInnerTexts()).map(s => s.replace(/\s+/g, ' ').replace(/:[0-9]+/g, ':XXX'));
expect(stack2.slice(0, 1)).toEqual([
'BrowserType.browserType._onWillCloseContext trace-viewer.spec.ts :XXX',
]);
await expect(traceViewer.stackFrames).toContainText([
/BrowserType.browserType._onWillCloseContext\s+trace-viewer.spec.ts\s+:\d+/,
], { useInnerText: true });
});
test('should have network requests', async ({ showTraceViewer }) => {

View file

@ -111,7 +111,7 @@ declare global {
/**
* Asserts element's text content matches given pattern or contains given substring.
*/
toContainText(expected: string, options?: { timeout?: number, useInnerText?: boolean }): Promise<R>;
toContainText(expected: string | RegExp | (string|RegExp)[], options?: { timeout?: number, useInnerText?: boolean }): Promise<R>;
/**
* Asserts element's attributes `name` matches expected value.