Skip to content

Commit

Permalink
feat(expect): narrow down available assertions for Page/Locator/APIRe…
Browse files Browse the repository at this point in the history
…sponse (#26658)

Fixes #26381.
  • Loading branch information
dgozman committed Aug 23, 2023
1 parent 197f79c commit 81cc39e
Show file tree
Hide file tree
Showing 3 changed files with 117 additions and 76 deletions.
65 changes: 34 additions & 31 deletions packages/playwright-test/types/test.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4610,9 +4610,6 @@ interface AsymmetricMatchers {
stringMatching(sample: string | RegExp): AsymmetricMatcher;
}

type IfAny<T, Y, N> = 0 extends (1 & T) ? Y : N;
type ExtraMatchers<T, Type, Matchers> = T extends Type ? Matchers : IfAny<T, Matchers, {}>;

/**
* 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
Expand Down Expand Up @@ -5067,33 +5064,41 @@ interface GenericAssertions<R> {

}

type BaseMatchers<R, T> = GenericAssertions<R> & PlaywrightTest.Matchers<R, T>;
type FunctionAssertions = {
/**
* Retries the callback until it passes.
*/
toPass(options?: { timeout?: number, intervals?: number[] }): Promise<void>;
};

type MakeMatchers<R, T> = BaseMatchers<R, T> & {
/**
* If you know how to test something, `.not` lets you test its opposite.
*/
not: MakeMatchers<R, T>;
/**
* 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<Promise<R>, Awaited<T>>;
/**
* Unwraps the reason of a rejected promise so any other matcher can be chained.
* If the promise is fulfilled the assertion fails.
*/
rejects: MakeMatchers<Promise<R>, Awaited<T>>;
} & SnapshotAssertions &
ExtraMatchers<T, Page, PageAssertions> &
ExtraMatchers<T, Locator, LocatorAssertions> &
ExtraMatchers<T, APIResponse, APIResponseAssertions> &
ExtraMatchers<T, Function, {
/**
* Retries the callback until it passes.
*/
toPass(options?: { timeout?: number, intervals?: number[] }): Promise<void>;
}>;
type BaseMatchers<R, T> = GenericAssertions<R> & PlaywrightTest.Matchers<R, T> & SnapshotAssertions;
type AllowedGenericMatchers<R> = Pick<GenericAssertions<R>, 'toBe' | 'toBeDefined' | 'toBeFalsy' | 'toBeNull' | 'toBeTruthy' | 'toBeUndefined'>;

type SpecificMatchers<R, T> =
T extends Page ? PageAssertions & AllowedGenericMatchers<R> :
T extends Locator ? LocatorAssertions & AllowedGenericMatchers<R> :
T extends APIResponse ? APIResponseAssertions & AllowedGenericMatchers<R> :
BaseMatchers<R, T> & (T extends Function ? FunctionAssertions : {});
type AllMatchers<R, T> = PageAssertions & LocatorAssertions & APIResponseAssertions & FunctionAssertions & BaseMatchers<R, T>;

type IfAny<T, Y, N> = 0 extends (1 & T) ? Y : N;
type Awaited<T> = T extends PromiseLike<infer U> ? U : T;
type MakeMatchers<R, T> = {
/**
* If you know how to test something, `.not` lets you test its opposite.
*/
not: MakeMatchers<R, T>;
/**
* 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<Promise<R>, Awaited<T>>;
/**
* Unwraps the reason of a rejected promise so any other matcher can be chained.
* If the promise is fulfilled the assertion fails.
*/
rejects: MakeMatchers<Promise<R>, any>;
} & IfAny<T, AllMatchers<R, T>, SpecificMatchers<R, T>>;

export type Expect = {
<T = unknown>(actual: T, messageOrOptions?: string | { message?: string }): MakeMatchers<void, T>;
Expand All @@ -5119,8 +5124,6 @@ export type Expect = {
not: Omit<AsymmetricMatchers, 'any' | 'anything'>;
} & AsymmetricMatchers;

type Awaited<T> = T extends PromiseLike<infer U> ? U : T;

// --- BEGINGLOBAL ---
declare global {
export namespace PlaywrightTest {
Expand Down
63 changes: 49 additions & 14 deletions tests/playwright-test/expect.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
});
`
});
Expand Down
65 changes: 34 additions & 31 deletions utils/generate_types/overrides-test.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -294,9 +294,6 @@ interface AsymmetricMatchers {
stringMatching(sample: string | RegExp): AsymmetricMatcher;
}

type IfAny<T, Y, N> = 0 extends (1 & T) ? Y : N;
type ExtraMatchers<T, Type, Matchers> = T extends Type ? Matchers : IfAny<T, Matchers, {}>;

interface GenericAssertions<R> {
not: GenericAssertions<R>;
toBe(expected: unknown): R;
Expand Down Expand Up @@ -325,33 +322,41 @@ interface GenericAssertions<R> {
toThrowError(error?: unknown): R;
}

type BaseMatchers<R, T> = GenericAssertions<R> & PlaywrightTest.Matchers<R, T>;
type FunctionAssertions = {
/**
* Retries the callback until it passes.
*/
toPass(options?: { timeout?: number, intervals?: number[] }): Promise<void>;
};

type MakeMatchers<R, T> = BaseMatchers<R, T> & {
/**
* If you know how to test something, `.not` lets you test its opposite.
*/
not: MakeMatchers<R, T>;
/**
* 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<Promise<R>, Awaited<T>>;
/**
* Unwraps the reason of a rejected promise so any other matcher can be chained.
* If the promise is fulfilled the assertion fails.
*/
rejects: MakeMatchers<Promise<R>, Awaited<T>>;
} & SnapshotAssertions &
ExtraMatchers<T, Page, PageAssertions> &
ExtraMatchers<T, Locator, LocatorAssertions> &
ExtraMatchers<T, APIResponse, APIResponseAssertions> &
ExtraMatchers<T, Function, {
/**
* Retries the callback until it passes.
*/
toPass(options?: { timeout?: number, intervals?: number[] }): Promise<void>;
}>;
type BaseMatchers<R, T> = GenericAssertions<R> & PlaywrightTest.Matchers<R, T> & SnapshotAssertions;
type AllowedGenericMatchers<R> = Pick<GenericAssertions<R>, 'toBe' | 'toBeDefined' | 'toBeFalsy' | 'toBeNull' | 'toBeTruthy' | 'toBeUndefined'>;

type SpecificMatchers<R, T> =
T extends Page ? PageAssertions & AllowedGenericMatchers<R> :
T extends Locator ? LocatorAssertions & AllowedGenericMatchers<R> :
T extends APIResponse ? APIResponseAssertions & AllowedGenericMatchers<R> :
BaseMatchers<R, T> & (T extends Function ? FunctionAssertions : {});
type AllMatchers<R, T> = PageAssertions & LocatorAssertions & APIResponseAssertions & FunctionAssertions & BaseMatchers<R, T>;

type IfAny<T, Y, N> = 0 extends (1 & T) ? Y : N;
type Awaited<T> = T extends PromiseLike<infer U> ? U : T;
type MakeMatchers<R, T> = {
/**
* If you know how to test something, `.not` lets you test its opposite.
*/
not: MakeMatchers<R, T>;
/**
* 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<Promise<R>, Awaited<T>>;
/**
* Unwraps the reason of a rejected promise so any other matcher can be chained.
* If the promise is fulfilled the assertion fails.
*/
rejects: MakeMatchers<Promise<R>, any>;
} & IfAny<T, AllMatchers<R, T>, SpecificMatchers<R, T>>;

export type Expect = {
<T = unknown>(actual: T, messageOrOptions?: string | { message?: string }): MakeMatchers<void, T>;
Expand All @@ -377,8 +382,6 @@ export type Expect = {
not: Omit<AsymmetricMatchers, 'any' | 'anything'>;
} & AsymmetricMatchers;

type Awaited<T> = T extends PromiseLike<infer U> ? U : T;

// --- BEGINGLOBAL ---
declare global {
export namespace PlaywrightTest {
Expand Down

0 comments on commit 81cc39e

Please sign in to comment.