diff --git a/docs/src/api/class-locatorassertions.md b/docs/src/api/class-locatorassertions.md index ad06c25142..9b5d983e76 100644 --- a/docs/src/api/class-locatorassertions.md +++ b/docs/src/api/class-locatorassertions.md @@ -217,6 +217,12 @@ Expected substring or RegExp or a list of those. Whether to perform case-insensitive match. [`option: ignoreCase`] option takes precedence over the corresponding regular expression flag if specified. +### option: LocatorAssertions.NotToContainText.ignoreOrder +* since: v1.44 +- `ignoreOrder` <[boolean]> + +Weather to ignore the order of the elements when comparing the text. This option is only available when the expected value is an array. + ### option: LocatorAssertions.NotToContainText.useInnerText * since: v1.18 - `useInnerText` <[boolean]> @@ -358,6 +364,12 @@ Expected string or RegExp or a list of those. Whether to perform case-insensitive match. [`option: ignoreCase`] option takes precedence over the corresponding regular expression flag if specified. +### option: LocatorAssertions.NotToHaveText.ignoreOrder +* since: v1.44 +- `ignoreOrder` <[boolean]> + +Weather to ignore the order of the elements when comparing the text. This option is only available when the expected value is an array. + ### option: LocatorAssertions.NotToHaveText.useInnerText * since: v1.18 - `useInnerText` <[boolean]> @@ -1095,6 +1107,12 @@ Expected substring or RegExp or a list of those. Whether to perform case-insensitive match. [`option: ignoreCase`] option takes precedence over the corresponding regular expression flag if specified. +### option: LocatorAssertions.toContainText.ignoreOrder +* since: v1.44 +- `ignoreOrder` <[boolean]> + +Weather to ignore the order of the elements when comparing the text. This option is only available when the expected value is an array. + ### option: LocatorAssertions.toContainText.useInnerText * since: v1.18 - `useInnerText` <[boolean]> @@ -1775,6 +1793,12 @@ Expected string or RegExp or a list of those. Whether to perform case-insensitive match. [`option: ignoreCase`] option takes precedence over the corresponding regular expression flag if specified. +### option: LocatorAssertions.toHaveText.ignoreOrder +* since: v1.44 +- `ignoreOrder` <[boolean]> + +Weather to ignore the order of the elements when comparing the text. This option is only available when the expected value is an array. + ### option: LocatorAssertions.toHaveText.useInnerText * since: v1.18 - `useInnerText` <[boolean]> diff --git a/packages/playwright-core/src/protocol/validator.ts b/packages/playwright-core/src/protocol/validator.ts index 2ec820711a..68544290df 100644 --- a/packages/playwright-core/src/protocol/validator.ts +++ b/packages/playwright-core/src/protocol/validator.ts @@ -1660,6 +1660,7 @@ scheme.FrameExpectParams = tObject({ expectedNumber: tOptional(tNumber), expectedValue: tOptional(tType('SerializedArgument')), useInnerText: tOptional(tBoolean), + ignoreOrder: tOptional(tBoolean), isNot: tBoolean, timeout: tOptional(tNumber), }); diff --git a/packages/playwright-core/src/server/injected/injectedScript.ts b/packages/playwright-core/src/server/injected/injectedScript.ts index 8a73b9e423..aa54c52f27 100644 --- a/packages/playwright-core/src/server/injected/injectedScript.ts +++ b/packages/playwright-core/src/server/injected/injectedScript.ts @@ -1268,13 +1268,26 @@ export class InjectedScript { // Each matcher should get a "received" that matches it, in order. const matchers = options.expectedText.map(e => new ExpectedTextMatcher(e)); - let mIndex = 0, rIndex = 0; - while (mIndex < matchers.length && rIndex < received.length) { - if (matchers[mIndex].matches(received[rIndex])) - ++mIndex; - ++rIndex; + if (options.ignoreOrder) { + const mReceivedIndexes = new Array(received.length).fill(false); + let mIndex = 0; + while (mIndex < matchers.length) { + for (let rIndex = 0; rIndex < received.length && mReceivedIndexes[mIndex]; rIndex++) { + if (matchers[mIndex].matches(received[rIndex])) + mReceivedIndexes[rIndex] = true; + } + mIndex++; + } + return { received, matches: mIndex === matchers.length }; + } else { + let mIndex = 0, rIndex = 0; + while (mIndex < matchers.length && rIndex < received.length) { + if (matchers[mIndex].matches(received[rIndex])) + ++mIndex; + ++rIndex; + } + return { received, matches: mIndex === matchers.length }; } - return { received, matches: mIndex === matchers.length }; } throw this.createStacklessError('Unknown expect matcher: ' + expression); } diff --git a/packages/playwright/src/matchers/matchers.ts b/packages/playwright/src/matchers/matchers.ts index 1e3bdf9a45..1df566e019 100644 --- a/packages/playwright/src/matchers/matchers.ts +++ b/packages/playwright/src/matchers/matchers.ts @@ -159,12 +159,12 @@ export function toContainText( this: ExpectMatcherContext, locator: LocatorEx, expected: string | RegExp | (string | RegExp)[], - options: { timeout?: number, useInnerText?: boolean, ignoreCase?: boolean } = {}, + options: { timeout?: number, useInnerText?: boolean, ignoreCase?: boolean, ignoreOrder?: boolean } = {}, ) { if (Array.isArray(expected)) { return toEqual.call(this, 'toContainText', locator, 'Locator', async (isNot, timeout) => { const expectedText = toExpectedTextValues(expected, { matchSubstring: true, normalizeWhiteSpace: true, ignoreCase: options.ignoreCase }); - return await locator._expect('to.contain.text.array', { expectedText, isNot, useInnerText: options.useInnerText, timeout }); + return await locator._expect('to.contain.text.array', { expectedText, isNot, useInnerText: options.useInnerText, ignoreOrder: options.ignoreOrder, timeout }); }, expected, { ...options, contains: true }); } else { return toMatchText.call(this, 'toContainText', locator, 'Locator', async (isNot, timeout) => { @@ -270,12 +270,12 @@ export function toHaveText( this: ExpectMatcherContext, locator: LocatorEx, expected: string | RegExp | (string | RegExp)[], - options: { timeout?: number, useInnerText?: boolean, ignoreCase?: boolean } = {}, + options: { timeout?: number, useInnerText?: boolean, ignoreCase?: boolean, ignoreOrder?: boolean } = {}, ) { if (Array.isArray(expected)) { return toEqual.call(this, 'toHaveText', locator, 'Locator', async (isNot, timeout) => { const expectedText = toExpectedTextValues(expected, { normalizeWhiteSpace: true, ignoreCase: options.ignoreCase }); - return await locator._expect('to.have.text.array', { expectedText, isNot, useInnerText: options?.useInnerText, timeout }); + return await locator._expect('to.have.text.array', { expectedText, isNot, useInnerText: options?.useInnerText, ignoreOrder: options.ignoreOrder, timeout }); }, expected, options); } else { return toMatchText.call(this, 'toHaveText', locator, 'Locator', async (isNot, timeout) => { diff --git a/packages/playwright/types/test.d.ts b/packages/playwright/types/test.d.ts index 7883711476..738e07b98a 100644 --- a/packages/playwright/types/test.d.ts +++ b/packages/playwright/types/test.d.ts @@ -6869,6 +6869,12 @@ interface LocatorAssertions { */ ignoreCase?: boolean; + /** + * Weather to ignore the order of the elements when comparing the text. This option is only available when the + * expected value is an array. + */ + ignoreOrder?: boolean; + /** * Time to retry the assertion for in milliseconds. Defaults to `timeout` in `TestConfig.expect`. */ @@ -7282,6 +7288,12 @@ interface LocatorAssertions { */ ignoreCase?: boolean; + /** + * Weather to ignore the order of the elements when comparing the text. This option is only available when the + * expected value is an array. + */ + ignoreOrder?: boolean; + /** * Time to retry the assertion for in milliseconds. Defaults to `timeout` in `TestConfig.expect`. */ diff --git a/packages/protocol/src/channels.ts b/packages/protocol/src/channels.ts index a35f12791a..5046d9a35d 100644 --- a/packages/protocol/src/channels.ts +++ b/packages/protocol/src/channels.ts @@ -2968,6 +2968,7 @@ export type FrameExpectParams = { expectedNumber?: number, expectedValue?: SerializedArgument, useInnerText?: boolean, + ignoreOrder?: boolean, isNot: boolean, timeout?: number, }; @@ -2977,6 +2978,7 @@ export type FrameExpectOptions = { expectedNumber?: number, expectedValue?: SerializedArgument, useInnerText?: boolean, + ignoreOrder?: boolean, timeout?: number, }; export type FrameExpectResult = { diff --git a/packages/protocol/src/protocol.yml b/packages/protocol/src/protocol.yml index 8c9e76baef..f8aef520ef 100644 --- a/packages/protocol/src/protocol.yml +++ b/packages/protocol/src/protocol.yml @@ -2257,6 +2257,7 @@ Frame: expectedNumber: number? expectedValue: SerializedArgument? useInnerText: boolean? + ignoreOrder: boolean? isNot: boolean timeout: number? returns: diff --git a/tests/page/expect-to-have-text.spec.ts b/tests/page/expect-to-have-text.spec.ts index 12a11d1541..dee1372cea 100644 --- a/tests/page/expect-to-have-text.spec.ts +++ b/tests/page/expect-to-have-text.spec.ts @@ -234,6 +234,19 @@ test.describe('toHaveText with array', () => { const error = await expect(locator).toContainText(['KekFoo', 'KekFoo', 'KekFoo'], { timeout: 1000 }).catch(e => e); expect(error.message).toContain('locator resolved to 1 element'); }); + + test('ignoreOrder should work with strings', async ({ page }) => { + await page.setContent('
Text 1
Text 2
'); + const locator = page.locator('div'); + await expect(locator).toHaveText(['Text 2', 'Text 1'], { ignoreOrder: true }); + }); + + test('ignoreOrder should work with regex', async ({ page }) => { + await page.setContent('
Text 1
Text 2
'); + const locator = page.locator('div'); + await expect(locator).toHaveText([/Text 2/, /Text 1/], { ignoreOrder: true }); + await expect(expect(locator).toHaveText([/Text 2/, /Text 1/, /Text 43/], { ignoreOrder: true, timeout: 10 })).rejects.toThrow('Timed out 10ms waiting for'); + }); }); test.describe('toContainText with array', () => { @@ -251,4 +264,16 @@ test.describe('toContainText with array', () => { const error = await expect(locator).toContainText(['Text 2'], { timeout: 1000 }).catch(e => e); expect(stripAnsi(error.message)).toContain('- "Text 2"'); }); + + test('ignoreOrder should work with strings', async ({ page }) => { + await page.setContent('
Text 1
Text 2
Text 3
'); + const locator = page.locator('div'); + await expect(locator).toContainText(['ext 2', 'ext 1'], { ignoreOrder: true }); + }); + + test('ignoreOrder should work with regex', async ({ page }) => { + await page.setContent('
Text 1
Text 2
Text 3
'); + const locator = page.locator('div'); + await expect(locator).toContainText([/ext 2/, /ext 1/], { ignoreOrder: true }); + }); });