From e0a87e52d7ca3aedf3ef4a19ff3d36cb15edee2b Mon Sep 17 00:00:00 2001 From: Ross Wollman Date: Thu, 2 Jun 2022 12:10:28 -0400 Subject: [PATCH] feat: support multi-select/combo box with toHaveValue (#14555) --- docs/src/api/class-locatorassertions.md | 8 +- .../src/server/injected/injectedScript.ts | 16 +- .../playwright-test/src/matchers/matchers.ts | 17 +- packages/playwright-test/types/test.d.ts | 4 +- .../playwright.expect.text.spec.ts | 181 ++++++++++++++++++ 5 files changed, 214 insertions(+), 12 deletions(-) diff --git a/docs/src/api/class-locatorassertions.md b/docs/src/api/class-locatorassertions.md index 7ba990214e..fdf918af12 100644 --- a/docs/src/api/class-locatorassertions.md +++ b/docs/src/api/class-locatorassertions.md @@ -293,9 +293,9 @@ Whether to use `element.innerText` instead of `element.textContent` when retriev The opposite of [`method: LocatorAssertions.toHaveValue`]. ### param: LocatorAssertions.NotToHaveValue.value -- `value` <[string]|[RegExp]> +- `value` <[string]|[RegExp]|[Array]<[string]|[RegExp]>> -Expected value. +Expected value. A list of expected values can be used if the Locator is a `select` element with the `multiple` attribute. ### option: LocatorAssertions.NotToHaveValue.timeout = %%-js-assertions-timeout-%% ### option: LocatorAssertions.NotToHaveValue.timeout = %%-csharp-java-python-assertions-timeout-%% @@ -1201,9 +1201,9 @@ await Expect(locator).ToHaveValueAsync(new Regex("[0-9]")); ``` ### param: LocatorAssertions.toHaveValue.value -- `value` <[string]|[RegExp]> +- `value` <[string]|[RegExp]|[Array]<[string]|[RegExp]>> -Expected value. +Expected value. A list of expected values can be used if the Locator is a `select` element with the `multiple` attribute. ### option: LocatorAssertions.toHaveValue.timeout = %%-js-assertions-timeout-%% ### option: LocatorAssertions.toHaveValue.timeout = %%-csharp-java-python-assertions-timeout-%% diff --git a/packages/playwright-core/src/server/injected/injectedScript.ts b/packages/playwright-core/src/server/injected/injectedScript.ts index 913a80e824..a6f6f95ea4 100644 --- a/packages/playwright-core/src/server/injected/injectedScript.ts +++ b/packages/playwright-core/src/server/injected/injectedScript.ts @@ -1051,6 +1051,20 @@ export class InjectedScript { } } + // Multi-Select/Combobox + { + if (expression === 'to.have.value' && options.expectedText?.length && options.expectedText.length >= 2) { + element = this.retarget(element, 'follow-label')!; + if (element.nodeName !== 'SELECT' || !(element as HTMLSelectElement).multiple) + throw this.createStacklessError('Not a select element with a multiple attribute'); + + const received = [...(element as HTMLSelectElement).selectedOptions].map(o => o.value); + if (received.length !== options.expectedText.length) + return { received, matches: false }; + return { received, matches: received.map((r, i) => new ExpectedTextMatcher(options.expectedText![i]).matches(r)).every(Boolean) }; + } + } + { // Single text value. let received: string | undefined; @@ -1072,7 +1086,7 @@ export class InjectedScript { element = this.retarget(element, 'follow-label')!; if (element.nodeName !== 'INPUT' && element.nodeName !== 'TEXTAREA' && element.nodeName !== 'SELECT') throw this.createStacklessError('Not an input element'); - received = (element as any).value; + received = (element as HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement).value; } if (received !== undefined && options.expectedText) { diff --git a/packages/playwright-test/src/matchers/matchers.ts b/packages/playwright-test/src/matchers/matchers.ts index 36c91a7c49..81f9ebd63a 100644 --- a/packages/playwright-test/src/matchers/matchers.ts +++ b/packages/playwright-test/src/matchers/matchers.ts @@ -234,13 +234,20 @@ export function toHaveText( export function toHaveValue( this: ReturnType, locator: LocatorEx, - expected: string | RegExp, + expected: string | RegExp | (string | RegExp)[], options?: { timeout?: number }, ) { - return toMatchText.call(this, 'toHaveValue', locator, 'Locator', async (isNot, timeout, customStackTrace) => { - const expectedText = toExpectedTextValues([expected]); - return await locator._expect(customStackTrace, 'to.have.value', { expectedText, isNot, timeout }); - }, expected, options); + if (Array.isArray(expected)) { + return toEqual.call(this, 'toHaveValue', locator, 'Locator', async (isNot, timeout, customStackTrace) => { + const expectedText = toExpectedTextValues(expected); + return await locator._expect(customStackTrace, 'to.have.value', { expectedText, isNot, timeout }); + }, expected, options); + } else { + return toMatchText.call(this, 'toHaveValue', locator, 'Locator', async (isNot, timeout, customStackTrace) => { + const expectedText = toExpectedTextValues([expected]); + return await locator._expect(customStackTrace, 'to.have.value', { expectedText, isNot, timeout }); + }, expected, options); + } } export function toHaveTitle( diff --git a/packages/playwright-test/types/test.d.ts b/packages/playwright-test/types/test.d.ts index b3918f9d74..62861ec1cc 100644 --- a/packages/playwright-test/types/test.d.ts +++ b/packages/playwright-test/types/test.d.ts @@ -3528,10 +3528,10 @@ interface LocatorAssertions { * await expect(locator).toHaveValue(/[0-9]/); * ``` * - * @param value Expected value. + * @param value Expected value. A list of expected values can be used if the Locator is a `select` element with the `multiple` attribute. * @param options */ - toHaveValue(value: string|RegExp, options?: { + toHaveValue(value: string|RegExp|Array, options?: { /** * Time to retry the assertion for. Defaults to `timeout` in `TestConfig.expect`. */ diff --git a/tests/playwright-test/playwright.expect.text.spec.ts b/tests/playwright-test/playwright.expect.text.spec.ts index 07c6b91927..75e6118163 100644 --- a/tests/playwright-test/playwright.expect.text.spec.ts +++ b/tests/playwright-test/playwright.expect.text.spec.ts @@ -412,6 +412,187 @@ test('should support toHaveValue failing', async ({ runInlineTest }) => { expect(result.output).toContain('"Text content"'); }); +test.describe('should support toHaveValue with multi-select', () => { + test('works with text', async ({ runInlineTest }) => { + const result = await runInlineTest({ + 'a.test.ts': ` + const { test } = pwt; + + test('pass', async ({ page }) => { + await page.setContent(\` + + \`); + const locator = page.locator('select'); + await locator.selectOption(['R', 'G']); + await expect(locator).toHaveValue(['R', 'G']); + }); + `, + }, { workers: 1 }); + expect(result.passed).toBe(1); + expect(result.exitCode).toBe(0); + }); + + test('follows labels', async ({ runInlineTest }) => { + const result = await runInlineTest({ + 'a.test.ts': ` + const { test } = pwt; + + test('pass', async ({ page }) => { + await page.setContent(\` + + + \`); + const locator = page.locator('text=Pick a Color'); + await locator.selectOption(['R', 'G']); + await expect(locator).toHaveValue(['R', 'G']); + }); + `, + }, { workers: 1 }); + expect(result.passed).toBe(1); + expect(result.exitCode).toBe(0); + }); + + test('exact match with text', async ({ runInlineTest }) => { + const result = await runInlineTest({ + 'a.test.ts': ` + const { test } = pwt; + + test('pass', async ({ page }) => { + await page.setContent(\` + + \`); + const locator = page.locator('select'); + await locator.selectOption(['RR', 'GG']); + await expect(locator).toHaveValue(['R', 'G']); + }); + `, + }, { workers: 1 }); + expect(result.passed).toBe(0); + expect(result.exitCode).toBe(1); + expect(stripAnsi(result.output)).toContain(` + - Expected - 2 + + Received + 2 + + Array [ + - "R", + - "G", + + "RR", + + "GG", + ] +`); + }); + + test('works with regex', async ({ runInlineTest }) => { + const result = await runInlineTest({ + 'a.test.ts': ` + const { test } = pwt; + + test('pass', async ({ page }) => { + await page.setContent(\` + + \`); + const locator = page.locator('select'); + await locator.selectOption(['R', 'G']); + await expect(locator).toHaveValue([/R/, /G/]); + }); + `, + }, { workers: 1 }); + expect(result.passed).toBe(1); + expect(result.exitCode).toBe(0); + }); + + test('fails when items not selected', async ({ runInlineTest }) => { + const result = await runInlineTest({ + 'a.test.ts': ` + const { test } = pwt; + + test('pass', async ({ page }) => { + await page.setContent(\` + + \`); + const locator = page.locator('select'); + await locator.selectOption(['B']); + await expect(locator).toHaveValue([/R/, /G/]); + }); + `, + }, { workers: 1 }); + expect(result.passed).toBe(0); + expect(result.exitCode).toBe(1); + expect(stripAnsi(result.output)).toContain(` + - Expected - 2 + + Received + 1 + + Array [ + - /R/, + - /G/, + + "B", + ] +`); + }); + + test('fails when multiple not specified', async ({ runInlineTest }) => { + const result = await runInlineTest({ + 'a.test.ts': ` + const { test } = pwt; + + test('pass', async ({ page }) => { + await page.setContent(\` + + \`); + const locator = page.locator('select'); + await locator.selectOption(['B']); + await expect(locator).toHaveValue([/R/, /G/]); + }); + `, + }, { workers: 1 }); + expect(result.passed).toBe(0); + expect(result.exitCode).toBe(1); + expect(result.output).toContain('Not a select element with a multiple attribute'); + }); + + test('fails when not a select element', async ({ runInlineTest }) => { + const result = await runInlineTest({ + 'a.test.ts': ` + const { test } = pwt; + + test('pass', async ({ page }) => { + await page.setContent(\` + + \`); + const locator = page.locator('input'); + await expect(locator).toHaveValue([/R/, /G/]); + }); + `, + }, { workers: 1 }); + expect(result.passed).toBe(0); + expect(result.exitCode).toBe(1); + expect(result.output).toContain('Not a select element with a multiple attribute'); + }); +}); + test('should print expected/received before timeout', async ({ runInlineTest }) => { const result = await runInlineTest({ 'a.test.ts': `