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(); await expect(locator).toBeVisible();
``` ```
## expect(locator).toContainText(text, options?) ## expect(locator).toContainText(expected, options?)
- `text`: <[string]> Text to look for inside the element - `expected`: <[string] | [RegExp] | [Array]<[string]|[RegExp]>>
- `options` - `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. - `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 ```js
const locator = page.locator('.title'); const locator = page.locator('.title');
await expect(locator).toContainText('substring'); 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) ## 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. Ensures [Locator] resolves to an exact number of DOM nodes.
```js ```js
const list = page.locator('list > #component'); const list = page.locator('list > .component');
await expect(list).toHaveCount(3); await expect(list).toHaveCount(3);
``` ```
@ -181,7 +189,7 @@ await expect(list).toHaveCount(3);
- `options` - `options`
- `timeout`: <[number]> Time to retry assertion for, defaults to `timeout` in [`property: TestProject.expect`]. - `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 ```js
const locator = page.locator('button'); const locator = page.locator('button');
@ -224,13 +232,14 @@ Ensures [Locator] points to an element with the given text. You can use regular
```js ```js
const locator = page.locator('.title'); const locator = page.locator('.title');
await expect(locator).toHaveText(/Welcome, Test User/);
await expect(locator).toHaveText(/Welcome, .*/); await expect(locator).toHaveText(/Welcome, .*/);
``` ```
Note that if array is passed as an expected value, entire lists can be asserted: Note that if array is passed as an expected value, entire lists can be asserted:
```js ```js
const locator = page.locator('list > #component'); const locator = page.locator('list > .component');
await expect(locator).toHaveText(['Text 1', 'Text 2', 'Text 3']); 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) => { return poller((progress, continuePolling) => {
if (querySelectorAll) { if (querySelectorAll) {
const elements = injected.querySelectorAll(info.parsed, document); 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); return callback(progress, elements[0], taskData as T, elements, continuePolling);
} }

View file

@ -882,20 +882,26 @@ export class InjectedScript {
{ {
// List of values. // List of values.
let received: string[] | undefined; 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 || ''); received = elements.map(e => options.useInnerText ? (e as HTMLElement).innerText : e.textContent || '');
else if (expression === 'to.have.class.array') else if (expression === 'to.have.class.array')
received = elements.map(e => e.className); received = elements.map(e => e.className);
if (received && options.expectedText) { 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); progress.setIntermediateResult(received);
return continuePolling; 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)); const matchers = options.expectedText.map(e => new ExpectedTextMatcher(e));
for (let i = 0; i < received.length; ++i) { for (const matcher of matchers) {
if (matchers[i].matches(received[i]) === options.isNot) { while (i < received.length && matcher.matches(received[i]) === options.isNot)
i++;
if (i === received.length) {
progress.setIntermediateResult(received); progress.setIntermediateResult(received);
return continuePolling; return continuePolling;
} }

View file

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

View file

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

View file

@ -47,6 +47,37 @@ test('should support toHaveText w/ regex', async ({ runInlineTest }) => {
expect(result.exitCode).toBe(1); 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 }) => { test('should support toHaveText w/ text', async ({ runInlineTest }) => {
const result = await runInlineTest({ const result = await runInlineTest({
'a.test.ts': ` 'a.test.ts': `
@ -64,7 +95,7 @@ test('should support toHaveText w/ text', async ({ runInlineTest }) => {
const locator = page.locator('#node'); const locator = page.locator('#node');
await expect(locator).toContainText('Text'); await expect(locator).toContainText('Text');
// Should normalize whitespace. // Should normalize whitespace.
await expect(locator).toContainText(' Text content\\n '); await expect(locator).toContainText(' ext cont\\n ');
}); });
test('fail', async ({ page }) => { test('fail', async ({ page }) => {
@ -127,14 +158,43 @@ test('should support toHaveText w/ array', async ({ runInlineTest }) => {
test('fail', async ({ page }) => { test('fail', async ({ page }) => {
await page.setContent('<div>Text 1</div><div>Text 3</div>'); await page.setContent('<div>Text 1</div><div>Text 3</div>');
const locator = page.locator('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 }); }, { workers: 1 });
const output = stripAscii(result.output); const output = stripAscii(result.output);
expect(output).toContain('Error: expect(received).toHaveText(expected) // deep equality'); expect(output).toContain('Error: expect(received).toHaveText(expected) // deep equality');
expect(output).toContain('await expect(locator).toHaveText'); 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.passed).toBe(1);
expect(result.failed).toBe(1); expect(result.failed).toBe(1);
expect(result.exitCode).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.selectAction('page.click');
await traceViewer.showSourceTab(); await traceViewer.showSourceTab();
const stack1 = (await traceViewer.stackFrames.allInnerTexts()).map(s => s.replace(/\s+/g, ' ').replace(/:[0-9]+/g, ':XXX')); await expect(traceViewer.stackFrames).toContainText([
expect(stack1.slice(0, 2)).toEqual([ /doClick\s+trace-viewer.spec.ts\s+:\d+/,
'doClick trace-viewer.spec.ts :XXX', /recordTrace\s+trace-viewer.spec.ts\s+:\d+/,
'recordTrace trace-viewer.spec.ts :XXX', ], { useInnerText: true });
]);
await traceViewer.selectAction('page.hover'); await traceViewer.selectAction('page.hover');
await traceViewer.showSourceTab(); await traceViewer.showSourceTab();
const stack2 = (await traceViewer.stackFrames.allInnerTexts()).map(s => s.replace(/\s+/g, ' ').replace(/:[0-9]+/g, ':XXX')); await expect(traceViewer.stackFrames).toContainText([
expect(stack2.slice(0, 1)).toEqual([ /BrowserType.browserType._onWillCloseContext\s+trace-viewer.spec.ts\s+:\d+/,
'BrowserType.browserType._onWillCloseContext trace-viewer.spec.ts :XXX', ], { useInnerText: true });
]);
}); });
test('should have network requests', async ({ showTraceViewer }) => { 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. * 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. * Asserts element's attributes `name` matches expected value.