fix(expect): proper return types (#13334)

A few changes:
- `Matchers<R, T>` now carries both return and argument type.
- Based on the argument type, we apply playwright-specific Page/Locator matchers.
- Return type is usually void, unless wrapped with `expect.resolves`,
  `expect.rejects` or `expect.poll()`.
- To preserve compatibility with any extended types in the wild,
  argument type is optional.
This commit is contained in:
Dmitry Gozman 2022-04-05 16:11:11 -07:00 committed by GitHub
parent 6ca58e18cb
commit 4bb563b015
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
3 changed files with 76 additions and 32 deletions

View file

@ -563,7 +563,7 @@ For TypeScript, also add the following to `global.d.ts`. You don't need it for J
// global.d.ts // global.d.ts
declare global { declare global {
namespace PlaywrightTest { namespace PlaywrightTest {
interface Matchers<R> { interface Matchers<R, T> {
toBeWithinRange(a: number, b: number): R; toBeWithinRange(a: number, b: number): R;
} }
} }

View file

@ -22,15 +22,15 @@ export declare type AsymmetricMatcher = Record<string, any>;
type IfAny<T, Y, N> = 0 extends (1 & T) ? Y : N; type IfAny<T, Y, N> = 0 extends (1 & T) ? Y : N;
type ExtraMatchers<T, Type, Matchers> = T extends Type ? Matchers : IfAny<T, Matchers, {}>; type ExtraMatchers<T, Type, Matchers> = T extends Type ? Matchers : IfAny<T, Matchers, {}>;
type MakeMatchers<T, ReturnValue = T> = PlaywrightTest.Matchers<ReturnValue> & type MakeMatchers<R, T> = PlaywrightTest.Matchers<R, T> &
ExtraMatchers<T, Page, PageMatchers> & ExtraMatchers<T, Page, PageMatchers> &
ExtraMatchers<T, Locator, LocatorMatchers> & ExtraMatchers<T, Locator, LocatorMatchers> &
ExtraMatchers<T, APIResponse, APIResponseMatchers> ExtraMatchers<T, APIResponse, APIResponseMatchers>;
export declare type Expect = { export declare type Expect = {
<T = unknown>(actual: T, messageOrOptions?: string | { message?: string }): MakeMatchers<T>; <T = unknown>(actual: T, messageOrOptions?: string | { message?: string }): MakeMatchers<void, T>;
soft: <T = unknown>(actual: T, messageOrOptions?: string | { message?: string }) => MakeMatchers<T>; soft: <T = unknown>(actual: T, messageOrOptions?: string | { message?: string }) => MakeMatchers<void, T>;
poll: <T = unknown>(actual: () => T | Promise<T>, messageOrOptions?: string | { message?: string, timeout?: number }) => Omit<PlaywrightTest.Matchers<T>, 'rejects' | 'resolves'>; poll: <T = unknown>(actual: () => T | Promise<T>, messageOrOptions?: string | { message?: string, timeout?: number }) => Omit<PlaywrightTest.Matchers<Promise<void>, T>, 'rejects' | 'resolves'>;
extend(arg0: any): void; extend(arg0: any): void;
getState(): expect.MatcherState; getState(): expect.MatcherState;
@ -109,21 +109,21 @@ type SupportedExpectProperties =
declare global { declare global {
export namespace PlaywrightTest { export namespace PlaywrightTest {
export interface Matchers<R> extends Pick<expect.Matchers<R>, SupportedExpectProperties> { export interface Matchers<R, T = unknown> extends Pick<expect.Matchers<R>, SupportedExpectProperties> {
/** /**
* If you know how to test something, `.not` lets you test its opposite. * If you know how to test something, `.not` lets you test its opposite.
*/ */
not: MakeMatchers<R>; not: MakeMatchers<R, T>;
/** /**
* Use resolves to unwrap the value of a fulfilled promise so any other * 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. * matcher can be chained. If the promise is rejected the assertion fails.
*/ */
resolves: MakeMatchers<Awaited<R>, R>; resolves: MakeMatchers<Promise<R>, Awaited<T>>;
/** /**
* Unwraps the reason of a rejected promise so any other matcher can be chained. * Unwraps the reason of a rejected promise so any other matcher can be chained.
* If the promise is fulfilled the assertion fails. * If the promise is fulfilled the assertion fails.
*/ */
rejects: MakeMatchers<Promise<R>>; rejects: MakeMatchers<Promise<R>, Awaited<T>>;
/** /**
* Match snapshot * Match snapshot
*/ */
@ -142,105 +142,105 @@ interface LocatorMatchers {
/** /**
* Asserts input is checked (or unchecked if { checked: false } is passed). * Asserts input is checked (or unchecked if { checked: false } is passed).
*/ */
toBeChecked(options?: { checked?: boolean, timeout?: number }): Promise<Locator>; toBeChecked(options?: { checked?: boolean, timeout?: number }): Promise<void>;
/** /**
* Asserts input is disabled. * Asserts input is disabled.
*/ */
toBeDisabled(options?: { timeout?: number }): Promise<Locator>; toBeDisabled(options?: { timeout?: number }): Promise<void>;
/** /**
* Asserts input is editable. * Asserts input is editable.
*/ */
toBeEditable(options?: { timeout?: number }): Promise<Locator>; toBeEditable(options?: { timeout?: number }): Promise<void>;
/** /**
* Asserts given DOM node or input has no text content or no input value. * Asserts given DOM node or input has no text content or no input value.
*/ */
toBeEmpty(options?: { timeout?: number }): Promise<Locator>; toBeEmpty(options?: { timeout?: number }): Promise<void>;
/** /**
* Asserts input is enabled. * Asserts input is enabled.
*/ */
toBeEnabled(options?: { timeout?: number }): Promise<Locator>; toBeEnabled(options?: { timeout?: number }): Promise<void>;
/** /**
* Asserts given DOM is a focused (active) in document. * Asserts given DOM is a focused (active) in document.
*/ */
toBeFocused(options?: { timeout?: number }): Promise<Locator>; toBeFocused(options?: { timeout?: number }): Promise<void>;
/** /**
* Asserts given DOM node is hidden or detached from DOM. * Asserts given DOM node is hidden or detached from DOM.
*/ */
toBeHidden(options?: { timeout?: number }): Promise<Locator>; toBeHidden(options?: { timeout?: number }): Promise<void>;
/** /**
* 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 | RegExp | (string | RegExp)[], options?: { timeout?: number, useInnerText?: boolean }): Promise<Locator>; toContainText(expected: string | RegExp | (string | RegExp)[], options?: { timeout?: number, useInnerText?: boolean }): Promise<void>;
/** /**
* Asserts element's attributes `name` matches expected value. * Asserts element's attributes `name` matches expected value.
*/ */
toHaveAttribute(name: string, expected: string | RegExp, options?: { timeout?: number }): Promise<Locator>; toHaveAttribute(name: string, expected: string | RegExp, options?: { timeout?: number }): Promise<void>;
/** /**
* Asserts that DOM node has a given CSS class. * Asserts that DOM node has a given CSS class.
*/ */
toHaveClass(className: string | RegExp | (string | RegExp)[], options?: { timeout?: number }): Promise<Locator>; toHaveClass(className: string | RegExp | (string | RegExp)[], options?: { timeout?: number }): Promise<void>;
/** /**
* Asserts number of DOM nodes matching given locator. * Asserts number of DOM nodes matching given locator.
*/ */
toHaveCount(expected: number, options?: { timeout?: number }): Promise<Locator>; toHaveCount(expected: number, options?: { timeout?: number }): Promise<void>;
/** /**
* Asserts element's computed CSS property `name` matches expected value. * Asserts element's computed CSS property `name` matches expected value.
*/ */
toHaveCSS(name: string, expected: string | RegExp, options?: { timeout?: number }): Promise<Locator>; toHaveCSS(name: string, expected: string | RegExp, options?: { timeout?: number }): Promise<void>;
/** /**
* Asserts element's `id` attribute matches expected value. * Asserts element's `id` attribute matches expected value.
*/ */
toHaveId(expected: string | RegExp, options?: { timeout?: number }): Promise<Locator>; toHaveId(expected: string | RegExp, options?: { timeout?: number }): Promise<void>;
/** /**
* Asserts JavaScript object that corresponds to the Node has a property with given value. * Asserts JavaScript object that corresponds to the Node has a property with given value.
*/ */
toHaveJSProperty(name: string, value: any, options?: { timeout?: number }): Promise<Locator>; toHaveJSProperty(name: string, value: any, options?: { timeout?: number }): Promise<void>;
/** /**
* Asserts element's text content. * Asserts element's text content.
*/ */
toHaveText(expected: string | RegExp | (string | RegExp)[], options?: { timeout?: number, useInnerText?: boolean }): Promise<Locator>; toHaveText(expected: string | RegExp | (string | RegExp)[], options?: { timeout?: number, useInnerText?: boolean }): Promise<void>;
/** /**
* Asserts input element's value. * Asserts input element's value.
*/ */
toHaveValue(expected: string | RegExp, options?: { timeout?: number }): Promise<Locator>; toHaveValue(expected: string | RegExp, options?: { timeout?: number }): Promise<void>;
/** /**
* Asserts given DOM node visible on the screen. * Asserts given DOM node visible on the screen.
*/ */
toBeVisible(options?: { timeout?: number }): Promise<Locator>; toBeVisible(options?: { timeout?: number }): Promise<void>;
} }
interface PageMatchers { interface PageMatchers {
/** /**
* Asserts page's title. * Asserts page's title.
*/ */
toHaveTitle(expected: string | RegExp, options?: { timeout?: number }): Promise<Page>; toHaveTitle(expected: string | RegExp, options?: { timeout?: number }): Promise<void>;
/** /**
* Asserts page's URL. * Asserts page's URL.
*/ */
toHaveURL(expected: string | RegExp, options?: { timeout?: number }): Promise<Page>; toHaveURL(expected: string | RegExp, options?: { timeout?: number }): Promise<void>;
} }
interface APIResponseMatchers { interface APIResponseMatchers {
/** /**
* Asserts given APIResponse's status is between 200 and 299. * Asserts given APIResponse's status is between 200 and 299.
*/ */
toBeOK(): Promise<APIResponse>; toBeOK(): Promise<void>;
} }
export { }; export { };

View file

@ -179,12 +179,13 @@ test('should work with default expect matchers and esModuleInterop=false', async
test('should work with custom PlaywrightTest namespace', async ({ runTSC }) => { test('should work with custom PlaywrightTest namespace', async ({ runTSC }) => {
const result = await runTSC({ const result = await runTSC({
'global.d.ts': ` 'global.d.ts': `
// Extracted example from their typings.
// Reference: https://github.com/jest-community/jest-extended/blob/master/types/index.d.ts
declare namespace PlaywrightTest { declare namespace PlaywrightTest {
interface Matchers<R> { interface Matchers<R> {
toBeEmpty(): R; toBeEmpty(): R;
} }
interface Matchers<R, T> {
toBeNonEmpty(): R;
}
} }
`, `,
'a.spec.ts': ` 'a.spec.ts': `
@ -199,6 +200,7 @@ test('should work with custom PlaywrightTest namespace', async ({ runTSC }) => {
test.expect(['hello']).not.toBeEmpty(); test.expect(['hello']).not.toBeEmpty();
test.expect({}).toBeEmpty(); test.expect({}).toBeEmpty();
test.expect({ hello: 'world' }).not.toBeEmpty(); test.expect({ hello: 'world' }).not.toBeEmpty();
test.expect('').toBeNonEmpty();
` `
}); });
expect(result.exitCode).toBe(0); expect(result.exitCode).toBe(0);
@ -234,6 +236,48 @@ test('should propose only the relevant matchers when custom expect matcher class
expect(result.exitCode).toBe(0); expect(result.exitCode).toBe(0);
}); });
test('should return void/Promise when appropriate', async ({ runTSC }) => {
const result = await runTSC({
'a.spec.ts': `
type AssertType<T, S> = S extends T ? AssertNotAny<S> : false;
type AssertNotAny<S> = {notRealProperty: number} extends S ? false : true;
pwt.test('example', async ({ page }) => {
{
const value = expect(1).toBe(2);
const assertion: AssertType<void, typeof value> = true;
}
{
const value = expect(1).not.toBe(2);
const assertion: AssertType<void, typeof value> = true;
}
{
const value = expect(page).toHaveURL('');
const assertion: AssertType<Promise<void>, typeof value> = true;
}
{
const value = expect(Promise.resolve(1)).resolves.toBe(1);
const assertion: AssertType<Promise<void>, typeof value> = true;
}
{
const value = expect.soft(1).toBe(2);
const assertion: AssertType<void, typeof value> = true;
}
{
const value = expect.poll(() => true).toBe(2);
const assertion: AssertType<Promise<void>, typeof value> = true;
}
});
`
});
expect(result.exitCode).toBe(0);
});
test.describe('helpful expect errors', () => { test.describe('helpful expect errors', () => {
test('top-level', async ({ runInlineTest }) => { test('top-level', async ({ runInlineTest }) => {
const result = await runInlineTest({ const result = await runInlineTest({