diff --git a/docs/src/test-assertions-js.md b/docs/src/test-assertions-js.md index 86af70d88c..14ac680d69 100644 --- a/docs/src/test-assertions-js.md +++ b/docs/src/test-assertions-js.md @@ -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']); ``` diff --git a/src/server/frames.ts b/src/server/frames.ts index 6841cb6eda..3e99bff193 100644 --- a/src/server/frames.ts +++ b/src/server/frames.ts @@ -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); } diff --git a/src/server/injected/injectedScript.ts b/src/server/injected/injectedScript.ts index 5ceeca39b0..762b9aa6fa 100644 --- a/src/server/injected/injectedScript.ts +++ b/src/server/injected/injectedScript.ts @@ -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; } diff --git a/src/test/matchers/matchers.ts b/src/test/matchers/matchers.ts index babf7814f0..9f67c534f1 100644 --- a/src/test/matchers/matchers.ts +++ b/src/test/matchers/matchers.ts @@ -109,13 +109,20 @@ export function toBeVisible( export function toContainText( this: ReturnType, 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( diff --git a/src/test/matchers/toEqual.ts b/src/test/matchers/toEqual.ts index 6b0a70d4cc..34a5c0f417 100644 --- a/src/test/matchers/toEqual.ts +++ b/src/test/matchers/toEqual.ts @@ -33,7 +33,7 @@ export async function toEqual( 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( expectType(receiver, receiverType, matcherName); const matcherOptions = { - comment: 'deep equality', + comment: options.contains ? '' : 'deep equality', isNot: this.isNot, promise: this.promise, }; diff --git a/tests/playwright-test/playwright.expect.text.spec.ts b/tests/playwright-test/playwright.expect.text.spec.ts index c46190915d..53f7447a0b 100644 --- a/tests/playwright-test/playwright.expect.text.spec.ts +++ b/tests/playwright-test/playwright.expect.text.spec.ts @@ -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('
Text content
'); + 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('
Text content
'); + 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('
Text 1
Text 3
'); 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('
Text \\n1
Text2
Text3
'); + const locator = page.locator('div'); + await expect(locator).toContainText(['ext 1', /ext3/]); + }); + + test('fail', async ({ page }) => { + await page.setContent('
Text 1
Text 3
'); + 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); diff --git a/tests/trace-viewer/trace-viewer.spec.ts b/tests/trace-viewer/trace-viewer.spec.ts index 50f8d723e6..1093c4aeff 100644 --- a/tests/trace-viewer/trace-viewer.spec.ts +++ b/tests/trace-viewer/trace-viewer.spec.ts @@ -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 }) => { diff --git a/types/testExpect.d.ts b/types/testExpect.d.ts index 334a4e41f0..a489505e1c 100644 --- a/types/testExpect.d.ts +++ b/types/testExpect.d.ts @@ -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; + toContainText(expected: string | RegExp | (string|RegExp)[], options?: { timeout?: number, useInnerText?: boolean }): Promise; /** * Asserts element's attributes `name` matches expected value.