From 81cc39ea6e389d790e85e30c1a0a7d885eb74b82 Mon Sep 17 00:00:00 2001 From: Dmitry Gozman Date: Wed, 23 Aug 2023 13:14:39 -0700 Subject: [PATCH] feat(expect): narrow down available assertions for Page/Locator/APIResponse (#26658) Fixes #26381. --- packages/playwright-test/types/test.d.ts | 65 +++++++++++++----------- tests/playwright-test/expect.spec.ts | 63 ++++++++++++++++++----- utils/generate_types/overrides-test.d.ts | 65 +++++++++++++----------- 3 files changed, 117 insertions(+), 76 deletions(-) diff --git a/packages/playwright-test/types/test.d.ts b/packages/playwright-test/types/test.d.ts index ea7eff81c5..cc6b4a3b37 100644 --- a/packages/playwright-test/types/test.d.ts +++ b/packages/playwright-test/types/test.d.ts @@ -4610,9 +4610,6 @@ interface AsymmetricMatchers { stringMatching(sample: string | RegExp): AsymmetricMatcher; } -type IfAny = 0 extends (1 & T) ? Y : N; -type ExtraMatchers = T extends Type ? Matchers : IfAny; - /** * The {@link GenericAssertions} class provides assertion methods that can be used to make assertions about any values * in the tests. A new instance of {@link GenericAssertions} is created by calling @@ -5067,33 +5064,41 @@ interface GenericAssertions { } -type BaseMatchers = GenericAssertions & PlaywrightTest.Matchers; +type FunctionAssertions = { + /** + * Retries the callback until it passes. + */ + toPass(options?: { timeout?: number, intervals?: number[] }): Promise; +}; -type MakeMatchers = BaseMatchers & { - /** - * If you know how to test something, `.not` lets you test its opposite. - */ - not: MakeMatchers; - /** - * Use resolves to unwrap the value of a fulfilled promise so any other - * matcher can be chained. If the promise is rejected the assertion fails. - */ - resolves: MakeMatchers, Awaited>; - /** - * Unwraps the reason of a rejected promise so any other matcher can be chained. - * If the promise is fulfilled the assertion fails. - */ - rejects: MakeMatchers, Awaited>; - } & SnapshotAssertions & - ExtraMatchers & - ExtraMatchers & - ExtraMatchers & - ExtraMatchers; - }>; +type BaseMatchers = GenericAssertions & PlaywrightTest.Matchers & SnapshotAssertions; +type AllowedGenericMatchers = Pick, 'toBe' | 'toBeDefined' | 'toBeFalsy' | 'toBeNull' | 'toBeTruthy' | 'toBeUndefined'>; + +type SpecificMatchers = + T extends Page ? PageAssertions & AllowedGenericMatchers : + T extends Locator ? LocatorAssertions & AllowedGenericMatchers : + T extends APIResponse ? APIResponseAssertions & AllowedGenericMatchers : + BaseMatchers & (T extends Function ? FunctionAssertions : {}); +type AllMatchers = PageAssertions & LocatorAssertions & APIResponseAssertions & FunctionAssertions & BaseMatchers; + +type IfAny = 0 extends (1 & T) ? Y : N; +type Awaited = T extends PromiseLike ? U : T; +type MakeMatchers = { + /** + * If you know how to test something, `.not` lets you test its opposite. + */ + not: MakeMatchers; + /** + * Use resolves to unwrap the value of a fulfilled promise so any other + * matcher can be chained. If the promise is rejected the assertion fails. + */ + resolves: MakeMatchers, Awaited>; + /** + * Unwraps the reason of a rejected promise so any other matcher can be chained. + * If the promise is fulfilled the assertion fails. + */ + rejects: MakeMatchers, any>; +} & IfAny, SpecificMatchers>; export type Expect = { (actual: T, messageOrOptions?: string | { message?: string }): MakeMatchers; @@ -5119,8 +5124,6 @@ export type Expect = { not: Omit; } & AsymmetricMatchers; -type Awaited = T extends PromiseLike ? U : T; - // --- BEGINGLOBAL --- declare global { export namespace PlaywrightTest { diff --git a/tests/playwright-test/expect.spec.ts b/tests/playwright-test/expect.spec.ts index 573b15cd7e..2afffb1ac2 100644 --- a/tests/playwright-test/expect.spec.ts +++ b/tests/playwright-test/expect.spec.ts @@ -294,44 +294,79 @@ test('should propose only the relevant matchers when custom expect matcher class 'a.spec.ts': ` import { test, expect } from '@playwright/test'; test('custom matchers', async ({ page }) => { + // Page-specific assertions apply to Page. await test.expect(page).toHaveURL('https://example.com'); await test.expect(page).not.toHaveURL('https://example.com'); - await test.expect(page).toBe(true); + // Some generic assertions also apply to Page. + test.expect(page).toBe(true); + test.expect(page).toBeDefined(); + test.expect(page).toBeFalsy(); + test.expect(page).toBeNull(); + test.expect(page).toBeTruthy(); + test.expect(page).toBeUndefined(); + + // Locator-specific and most generic assertions do not apply to Page. // @ts-expect-error await test.expect(page).toBeEnabled(); // @ts-expect-error await test.expect(page).not.toBeEnabled(); + // @ts-expect-error + test.expect(page).toEqual(); + // Locator-specific assertions apply to Locator. await test.expect(page.locator('foo')).toBeEnabled(); await test.expect(page.locator('foo')).toBeEnabled({ enabled: false }); await test.expect(page.locator('foo')).not.toBeEnabled({ enabled: true }); + await test.expect(page.locator('foo')).toBeChecked(); + await test.expect(page.locator('foo')).not.toBeChecked({ checked: true }); + await test.expect(page.locator('foo')).not.toBeEditable(); + await test.expect(page.locator('foo')).toBeEditable({ editable: false }); + await test.expect(page.locator('foo')).toBeVisible(); + await test.expect(page.locator('foo')).not.toBeVisible({ visible: false }); + // Some generic assertions also apply to Locator. + test.expect(page.locator('foo')).toBe(true); + + // Page-specific and most generic assertions do not apply to Locator. + // @ts-expect-error + await test.expect(page.locator('foo')).toHaveURL('https://example.com'); + // @ts-expect-error + await test.expect(page.locator('foo')).toHaveLength(1); + + // Wrong arguments for assertions do not compile. // @ts-expect-error await test.expect(page.locator('foo')).toBeEnabled({ unknown: false }); // @ts-expect-error await test.expect(page.locator('foo')).toBeEnabled({ enabled: 'foo' }); - await test.expect(page.locator('foo')).toBe(true); - // @ts-expect-error - await test.expect(page.locator('foo')).toHaveURL('https://example.com'); + // Generic assertions work. + test.expect([123]).toHaveLength(1); + test.expect('123').toMatchSnapshot('name'); + test.expect(await page.screenshot()).toMatchSnapshot('screenshot.png'); + // All possible assertions apply to "any" type. + const x: any = 123; + test.expect(x).toHaveLength(1); + await test.expect(x).toHaveURL('url'); + await test.expect(x).toBeEnabled(); + test.expect(x).toMatchSnapshot('snapshot name'); + + // APIResponse-specific assertions apply to APIResponse. const res = await page.request.get('http://i-do-definitely-not-exist.com'); await test.expect(res).toBeOK(); - await test.expect(res).toBe(true); + // Some generic assertions also apply to APIResponse. + test.expect(res).toBe(true); + // Page-specific and most generic assertions do not apply to APIResponse. // @ts-expect-error await test.expect(res).toHaveURL('https://example.com'); + // @ts-expect-error + test.expect(res).toEqual(123); + // Explicitly casting to "any" supports all assertions. await test.expect(res as any).toHaveURL('https://example.com'); + + // Playwright-specific assertions do not apply to generic values. // @ts-expect-error await test.expect(123).toHaveURL('https://example.com'); - - await test.expect(page.locator('foo')).toBeChecked(); - await test.expect(page.locator('foo')).not.toBeChecked({ checked: true }); - - await test.expect(page.locator('foo')).not.toBeEditable(); - await test.expect(page.locator('foo')).toBeEditable({ editable: false }); - - await test.expect(page.locator('foo')).toBeVisible(); - await test.expect(page.locator('foo')).not.toBeVisible({ visible: false }); }); ` }); diff --git a/utils/generate_types/overrides-test.d.ts b/utils/generate_types/overrides-test.d.ts index de88ed4f5f..3a1f578fa1 100644 --- a/utils/generate_types/overrides-test.d.ts +++ b/utils/generate_types/overrides-test.d.ts @@ -294,9 +294,6 @@ interface AsymmetricMatchers { stringMatching(sample: string | RegExp): AsymmetricMatcher; } -type IfAny = 0 extends (1 & T) ? Y : N; -type ExtraMatchers = T extends Type ? Matchers : IfAny; - interface GenericAssertions { not: GenericAssertions; toBe(expected: unknown): R; @@ -325,33 +322,41 @@ interface GenericAssertions { toThrowError(error?: unknown): R; } -type BaseMatchers = GenericAssertions & PlaywrightTest.Matchers; +type FunctionAssertions = { + /** + * Retries the callback until it passes. + */ + toPass(options?: { timeout?: number, intervals?: number[] }): Promise; +}; -type MakeMatchers = BaseMatchers & { - /** - * If you know how to test something, `.not` lets you test its opposite. - */ - not: MakeMatchers; - /** - * Use resolves to unwrap the value of a fulfilled promise so any other - * matcher can be chained. If the promise is rejected the assertion fails. - */ - resolves: MakeMatchers, Awaited>; - /** - * Unwraps the reason of a rejected promise so any other matcher can be chained. - * If the promise is fulfilled the assertion fails. - */ - rejects: MakeMatchers, Awaited>; - } & SnapshotAssertions & - ExtraMatchers & - ExtraMatchers & - ExtraMatchers & - ExtraMatchers; - }>; +type BaseMatchers = GenericAssertions & PlaywrightTest.Matchers & SnapshotAssertions; +type AllowedGenericMatchers = Pick, 'toBe' | 'toBeDefined' | 'toBeFalsy' | 'toBeNull' | 'toBeTruthy' | 'toBeUndefined'>; + +type SpecificMatchers = + T extends Page ? PageAssertions & AllowedGenericMatchers : + T extends Locator ? LocatorAssertions & AllowedGenericMatchers : + T extends APIResponse ? APIResponseAssertions & AllowedGenericMatchers : + BaseMatchers & (T extends Function ? FunctionAssertions : {}); +type AllMatchers = PageAssertions & LocatorAssertions & APIResponseAssertions & FunctionAssertions & BaseMatchers; + +type IfAny = 0 extends (1 & T) ? Y : N; +type Awaited = T extends PromiseLike ? U : T; +type MakeMatchers = { + /** + * If you know how to test something, `.not` lets you test its opposite. + */ + not: MakeMatchers; + /** + * Use resolves to unwrap the value of a fulfilled promise so any other + * matcher can be chained. If the promise is rejected the assertion fails. + */ + resolves: MakeMatchers, Awaited>; + /** + * Unwraps the reason of a rejected promise so any other matcher can be chained. + * If the promise is fulfilled the assertion fails. + */ + rejects: MakeMatchers, any>; +} & IfAny, SpecificMatchers>; export type Expect = { (actual: T, messageOrOptions?: string | { message?: string }): MakeMatchers; @@ -377,8 +382,6 @@ export type Expect = { not: Omit; } & AsymmetricMatchers; -type Awaited = T extends PromiseLike ? U : T; - // --- BEGINGLOBAL --- declare global { export namespace PlaywrightTest {