mirror of
https://github.com/microsoft/playwright.git
synced 2025-06-26 21:40:17 +00:00
feat(expect): add text and true matchers (#7873)
This commit is contained in:
parent
74cd7584ac
commit
49e9f8c15e
@ -22,17 +22,14 @@ import { monotonicTime } from '../utils/utils';
|
|||||||
import { ElementHandle } from './elementHandle';
|
import { ElementHandle } from './elementHandle';
|
||||||
import { Frame } from './frame';
|
import { Frame } from './frame';
|
||||||
import { FilePayload, Rect, SelectOption, SelectOptionOptions, TimeoutOptions } from './types';
|
import { FilePayload, Rect, SelectOption, SelectOptionOptions, TimeoutOptions } from './types';
|
||||||
import { TimeoutSettings } from '../utils/timeoutSettings';
|
|
||||||
|
|
||||||
export class Locator implements api.Locator {
|
export class Locator implements api.Locator {
|
||||||
private _frame: Frame;
|
private _frame: Frame;
|
||||||
private _selector: string;
|
private _selector: string;
|
||||||
private _visibleSelector: string;
|
private _visibleSelector: string;
|
||||||
private _timeoutSettings: TimeoutSettings;
|
|
||||||
|
|
||||||
constructor(frame: Frame, selector: string) {
|
constructor(frame: Frame, selector: string) {
|
||||||
this._frame = frame;
|
this._frame = frame;
|
||||||
this._timeoutSettings = this._frame.page()._timeoutSettings;
|
|
||||||
this._selector = selector;
|
this._selector = selector;
|
||||||
this._visibleSelector = selector + ' >> _visible=true';
|
this._visibleSelector = selector + ' >> _visible=true';
|
||||||
}
|
}
|
||||||
@ -158,11 +155,11 @@ export class Locator implements api.Locator {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async isHidden(options?: TimeoutOptions): Promise<boolean> {
|
async isHidden(options?: TimeoutOptions): Promise<boolean> {
|
||||||
return this._frame.isHidden(this._visibleSelector, { strict: true, ...options });
|
return this._frame.isHidden(this._selector, { strict: true, ...options });
|
||||||
}
|
}
|
||||||
|
|
||||||
async isVisible(options?: TimeoutOptions): Promise<boolean> {
|
async isVisible(options?: TimeoutOptions): Promise<boolean> {
|
||||||
return this._frame.isVisible(this._visibleSelector, { strict: true, ...options });
|
return this._frame.isVisible(this._selector, { strict: true, ...options });
|
||||||
}
|
}
|
||||||
|
|
||||||
async press(key: string, options: channels.ElementHandlePressOptions = {}): Promise<void> {
|
async press(key: string, options: channels.ElementHandlePressOptions = {}): Promise<void> {
|
||||||
|
|||||||
@ -14,11 +14,29 @@
|
|||||||
* limitations under the License.
|
* limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { Expect } from './types';
|
|
||||||
import expectLibrary from 'expect';
|
import expectLibrary from 'expect';
|
||||||
|
import { toBeChecked, toBeDisabled, toBeEditable, toBeEmpty, toBeEnabled, toBeFocused, toBeHidden, toBeVisible } from './matchers/toBeTruthy';
|
||||||
import { toMatchSnapshot } from './matchers/toMatchSnapshot';
|
import { toMatchSnapshot } from './matchers/toMatchSnapshot';
|
||||||
import { toMatchText, toHaveText } from './matchers/toMatchText';
|
import { toContainText, toHaveAttr, toHaveCSS, toHaveData, toHaveId, toHaveText, toHaveValue } from './matchers/toMatchText';
|
||||||
|
import type { Expect } from './types';
|
||||||
|
|
||||||
export const expect: Expect = expectLibrary as any;
|
export const expect: Expect = expectLibrary as any;
|
||||||
expectLibrary.setState({ expand: false });
|
expectLibrary.setState({ expand: false });
|
||||||
expectLibrary.extend({ toMatchSnapshot, toMatchText, toHaveText });
|
expectLibrary.extend({
|
||||||
|
toBeChecked,
|
||||||
|
toBeDisabled,
|
||||||
|
toBeEditable,
|
||||||
|
toBeEmpty,
|
||||||
|
toBeEnabled,
|
||||||
|
toBeFocused,
|
||||||
|
toBeHidden,
|
||||||
|
toBeVisible,
|
||||||
|
toContainText,
|
||||||
|
toHaveAttr,
|
||||||
|
toHaveCSS,
|
||||||
|
toHaveData,
|
||||||
|
toHaveId,
|
||||||
|
toHaveText,
|
||||||
|
toHaveValue,
|
||||||
|
toMatchSnapshot,
|
||||||
|
});
|
||||||
|
|||||||
151
src/test/matchers/toBeTruthy.ts
Normal file
151
src/test/matchers/toBeTruthy.ts
Normal file
@ -0,0 +1,151 @@
|
|||||||
|
/**
|
||||||
|
* Copyright Microsoft Corporation. All rights reserved.
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
matcherHint,
|
||||||
|
MatcherHintOptions,
|
||||||
|
printReceived
|
||||||
|
} from 'jest-matcher-utils';
|
||||||
|
import { Locator } from '../../..';
|
||||||
|
import { currentTestInfo } from '../globals';
|
||||||
|
import type { Expect } from '../types';
|
||||||
|
import { monotonicTime, pollUntilDeadline } from '../util';
|
||||||
|
|
||||||
|
|
||||||
|
async function toBeTruthyImpl(
|
||||||
|
this: ReturnType<Expect['getState']>,
|
||||||
|
matcherName: string,
|
||||||
|
query: (timeout: number) => Promise<boolean>,
|
||||||
|
options: { timeout?: number } = {},
|
||||||
|
) {
|
||||||
|
const testInfo = currentTestInfo();
|
||||||
|
if (!testInfo)
|
||||||
|
throw new Error(`toMatchSnapshot() must be called during the test`);
|
||||||
|
|
||||||
|
const matcherOptions: MatcherHintOptions = {
|
||||||
|
isNot: this.isNot,
|
||||||
|
promise: this.promise,
|
||||||
|
};
|
||||||
|
|
||||||
|
let received: boolean;
|
||||||
|
let pass = false;
|
||||||
|
const timeout = options.timeout === 0 ? 0 : options.timeout || testInfo.timeout;
|
||||||
|
const deadline = timeout ? monotonicTime() + timeout : 0;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await pollUntilDeadline(async () => {
|
||||||
|
const remainingTime = deadline ? deadline - monotonicTime() : 0;
|
||||||
|
received = await query(remainingTime);
|
||||||
|
pass = !!received;
|
||||||
|
return pass === !matcherOptions.isNot;
|
||||||
|
}, deadline, 100);
|
||||||
|
} catch (e) {
|
||||||
|
pass = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const message = () =>
|
||||||
|
matcherHint(matcherName, undefined, '', matcherOptions) +
|
||||||
|
'\n\n' +
|
||||||
|
`Received: ${printReceived(received)}`;
|
||||||
|
|
||||||
|
return { message, pass };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function toBeChecked(
|
||||||
|
this: ReturnType<Expect['getState']>,
|
||||||
|
locator: Locator,
|
||||||
|
options?: { timeout?: number },
|
||||||
|
) {
|
||||||
|
return toBeTruthyImpl.call(this, 'toBeChecked', async timeout => {
|
||||||
|
return await locator.isChecked({ timeout });
|
||||||
|
}, options);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function toBeEditable(
|
||||||
|
this: ReturnType<Expect['getState']>,
|
||||||
|
locator: Locator,
|
||||||
|
options?: { timeout?: number },
|
||||||
|
) {
|
||||||
|
return toBeTruthyImpl.call(this, 'toBeEditable', async timeout => {
|
||||||
|
return await locator.isEditable({ timeout });
|
||||||
|
}, options);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function toBeEnabled(
|
||||||
|
this: ReturnType<Expect['getState']>,
|
||||||
|
locator: Locator,
|
||||||
|
options?: { timeout?: number },
|
||||||
|
) {
|
||||||
|
return toBeTruthyImpl.call(this, 'toBeEnabled', async timeout => {
|
||||||
|
return await locator.isEnabled({ timeout });
|
||||||
|
}, options);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function toBeDisabled(
|
||||||
|
this: ReturnType<Expect['getState']>,
|
||||||
|
locator: Locator,
|
||||||
|
options?: { timeout?: number },
|
||||||
|
) {
|
||||||
|
return toBeTruthyImpl.call(this, 'toBeDisabled', async timeout => {
|
||||||
|
return await locator.isDisabled({ timeout });
|
||||||
|
}, options);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function toBeEmpty(
|
||||||
|
this: ReturnType<Expect['getState']>,
|
||||||
|
locator: Locator,
|
||||||
|
options?: { timeout?: number },
|
||||||
|
) {
|
||||||
|
return toBeTruthyImpl.call(this, 'toBeEmpty', async timeout => {
|
||||||
|
return await locator.evaluate(element => {
|
||||||
|
if (element.nodeName === 'INPUT' || element.nodeName === 'TEXTAREA')
|
||||||
|
return !(element as HTMLInputElement).value;
|
||||||
|
return !element.textContent?.trim();
|
||||||
|
}, { timeout });
|
||||||
|
}, options);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function toBeHidden(
|
||||||
|
this: ReturnType<Expect['getState']>,
|
||||||
|
locator: Locator,
|
||||||
|
options?: { timeout?: number },
|
||||||
|
) {
|
||||||
|
return toBeTruthyImpl.call(this, 'toBeHidden', async timeout => {
|
||||||
|
return await locator.isHidden({ timeout });
|
||||||
|
}, options);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function toBeVisible(
|
||||||
|
this: ReturnType<Expect['getState']>,
|
||||||
|
locator: Locator,
|
||||||
|
options?: { timeout?: number },
|
||||||
|
) {
|
||||||
|
return toBeTruthyImpl.call(this, 'toBeVisible', async timeout => {
|
||||||
|
return await locator.isVisible({ timeout });
|
||||||
|
}, options);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function toBeFocused(
|
||||||
|
this: ReturnType<Expect['getState']>,
|
||||||
|
locator: Locator,
|
||||||
|
options?: { timeout?: number },
|
||||||
|
) {
|
||||||
|
return toBeTruthyImpl.call(this, 'toBeFocused', async timeout => {
|
||||||
|
return await locator.evaluate(element => {
|
||||||
|
return document.activeElement === element;
|
||||||
|
}, { timeout });
|
||||||
|
}, options);
|
||||||
|
}
|
||||||
@ -35,16 +35,15 @@ import { monotonicTime, pollUntilDeadline } from '../util';
|
|||||||
|
|
||||||
async function toMatchTextImpl(
|
async function toMatchTextImpl(
|
||||||
this: ReturnType<Expect['getState']>,
|
this: ReturnType<Expect['getState']>,
|
||||||
locator: Locator,
|
matcherName: string,
|
||||||
|
query: (timeout: number) => Promise<string>,
|
||||||
expected: string | RegExp,
|
expected: string | RegExp,
|
||||||
exactMatch: boolean,
|
options: { timeout?: number, matchSubstring?: boolean } = {},
|
||||||
options: { timeout?: number, useInnerText?: boolean } = {},
|
|
||||||
) {
|
) {
|
||||||
const testInfo = currentTestInfo();
|
const testInfo = currentTestInfo();
|
||||||
if (!testInfo)
|
if (!testInfo)
|
||||||
throw new Error(`toMatchSnapshot() must be called during the test`);
|
throw new Error(`toMatchSnapshot() must be called during the test`);
|
||||||
|
|
||||||
const matcherName = exactMatch ? 'toHaveText' : 'toMatchText';
|
|
||||||
const matcherOptions: MatcherHintOptions = {
|
const matcherOptions: MatcherHintOptions = {
|
||||||
isNot: this.isNot,
|
isNot: this.isNot,
|
||||||
promise: this.promise,
|
promise: this.promise,
|
||||||
@ -72,18 +71,22 @@ async function toMatchTextImpl(
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
await pollUntilDeadline(async () => {
|
await pollUntilDeadline(async () => {
|
||||||
received = options?.useInnerText ? await locator.innerText() : await locator.textContent() || '';
|
const remainingTime = deadline ? deadline - monotonicTime() : 0;
|
||||||
if (exactMatch)
|
received = await query(remainingTime);
|
||||||
pass = expected === received;
|
if (options.matchSubstring)
|
||||||
|
pass = received.includes(expected as string);
|
||||||
|
else if (typeof expected === 'string')
|
||||||
|
pass = received === expected;
|
||||||
else
|
else
|
||||||
pass = typeof expected === 'string' ? received.includes(expected) : new RegExp(expected).test(received);
|
pass = expected.test(received);
|
||||||
|
|
||||||
return pass === !matcherOptions.isNot;
|
return pass === !matcherOptions.isNot;
|
||||||
}, deadline, 100);
|
}, deadline, 100);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
pass = false;
|
pass = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
const stringSubstring = exactMatch ? 'string' : 'substring';
|
const stringSubstring = options.matchSubstring ? 'substring' : 'string';
|
||||||
const message = pass
|
const message = pass
|
||||||
? () =>
|
? () =>
|
||||||
typeof expected === 'string'
|
typeof expected === 'string'
|
||||||
@ -121,20 +124,88 @@ async function toMatchTextImpl(
|
|||||||
return { message, pass };
|
return { message, pass };
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function toMatchText(
|
export async function toHaveText(
|
||||||
this: ReturnType<Expect['getState']>,
|
this: ReturnType<Expect['getState']>,
|
||||||
locator: Locator,
|
locator: Locator,
|
||||||
expected: string | RegExp,
|
expected: string | RegExp,
|
||||||
options?: { timeout?: number, useInnerText?: boolean },
|
options?: { timeout?: number, useInnerText?: boolean },
|
||||||
) {
|
) {
|
||||||
return toMatchTextImpl.call(this, locator, expected, false, options);
|
return toMatchTextImpl.call(this, 'toHaveText', async timeout => {
|
||||||
|
if (options?.useInnerText)
|
||||||
|
return await locator.innerText({ timeout });
|
||||||
|
return await locator.textContent() || '';
|
||||||
|
}, expected, options);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function toHaveText(
|
export async function toContainText(
|
||||||
this: ReturnType<Expect['getState']>,
|
this: ReturnType<Expect['getState']>,
|
||||||
locator: Locator,
|
locator: Locator,
|
||||||
expected: string,
|
expected: string,
|
||||||
options?: { timeout?: number, useInnerText?: boolean },
|
options?: { timeout?: number, useInnerText?: boolean },
|
||||||
) {
|
) {
|
||||||
return toMatchTextImpl.call(this, locator, expected, true, options);
|
return toMatchTextImpl.call(this, 'toContainText', async timeout => {
|
||||||
|
if (options?.useInnerText)
|
||||||
|
return await locator.innerText({ timeout });
|
||||||
|
return await locator.textContent() || '';
|
||||||
|
}, expected, { ...options, matchSubstring: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function toHaveAttr(
|
||||||
|
this: ReturnType<Expect['getState']>,
|
||||||
|
locator: Locator,
|
||||||
|
name: string,
|
||||||
|
expected: string | RegExp,
|
||||||
|
options?: { timeout?: number },
|
||||||
|
) {
|
||||||
|
return toMatchTextImpl.call(this, 'toHaveAttr', async timeout => {
|
||||||
|
return await locator.getAttribute(name, { timeout }) || '';
|
||||||
|
}, expected, options);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function toHaveData(
|
||||||
|
this: ReturnType<Expect['getState']>,
|
||||||
|
locator: Locator,
|
||||||
|
name: string,
|
||||||
|
expected: string | RegExp,
|
||||||
|
options?: { timeout?: number },
|
||||||
|
) {
|
||||||
|
return toMatchTextImpl.call(this, 'toHaveData', async timeout => {
|
||||||
|
return await locator.getAttribute('data-' + name, { timeout }) || '';
|
||||||
|
}, expected, options);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function toHaveCSS(
|
||||||
|
this: ReturnType<Expect['getState']>,
|
||||||
|
locator: Locator,
|
||||||
|
name: string,
|
||||||
|
expected: string | RegExp,
|
||||||
|
options?: { timeout?: number },
|
||||||
|
) {
|
||||||
|
return toMatchTextImpl.call(this, 'toHaveCSS', async timeout => {
|
||||||
|
return await locator.evaluate(async (element, name) => {
|
||||||
|
return (window.getComputedStyle(element) as any)[name];
|
||||||
|
}, name, { timeout });
|
||||||
|
}, expected, options);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function toHaveId(
|
||||||
|
this: ReturnType<Expect['getState']>,
|
||||||
|
locator: Locator,
|
||||||
|
expected: string | RegExp,
|
||||||
|
options?: { timeout?: number },
|
||||||
|
) {
|
||||||
|
return toMatchTextImpl.call(this, 'toHaveId', async timeout => {
|
||||||
|
return await locator.getAttribute('id', { timeout }) || '';
|
||||||
|
}, expected, options);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function toHaveValue(
|
||||||
|
this: ReturnType<Expect['getState']>,
|
||||||
|
locator: Locator,
|
||||||
|
expected: string | RegExp,
|
||||||
|
options?: { timeout?: number },
|
||||||
|
) {
|
||||||
|
return toMatchTextImpl.call(this, 'toHaveValue', async timeout => {
|
||||||
|
return await locator.inputValue({ timeout });
|
||||||
|
}, expected, options);
|
||||||
}
|
}
|
||||||
@ -16,49 +16,55 @@
|
|||||||
|
|
||||||
import { test, expect, stripAscii } from './playwright-test-fixtures';
|
import { test, expect, stripAscii } from './playwright-test-fixtures';
|
||||||
|
|
||||||
test('should support toMatchText', async ({ runInlineTest }) => {
|
test('should support toHaveText w/ regex', async ({ runInlineTest }) => {
|
||||||
const result = await runInlineTest({
|
const result = await runInlineTest({
|
||||||
'a.test.ts': `
|
'a.test.ts': `
|
||||||
const { test } = pwt;
|
const { test } = pwt;
|
||||||
|
|
||||||
test('pass', async ({ page }) => {
|
test('pass', async ({ page }) => {
|
||||||
await page.setContent('<div id=node>Text content</div>');
|
await page.setContent('<div id=node>Text content</div>');
|
||||||
const handle = page.locator('#node');
|
const locator = page.locator('#node');
|
||||||
await expect(handle).toMatchText(/Text/);
|
await expect(locator).toHaveText(/Text/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('fail', async ({ page }) => {
|
test('fail', async ({ page }) => {
|
||||||
await page.setContent('<div id=node>Text content</div>');
|
await page.setContent('<div id=node>Text content</div>');
|
||||||
const handle = page.locator('#node');
|
const locator = page.locator('#node');
|
||||||
await expect(handle).toMatchText(/Text 2/, { timeout: 100 });
|
await expect(locator).toHaveText(/Text 2/, { timeout: 100 });
|
||||||
});
|
});
|
||||||
`,
|
`,
|
||||||
}, { workers: 1 });
|
}, { workers: 1 });
|
||||||
const output = stripAscii(result.output);
|
const output = stripAscii(result.output);
|
||||||
expect(output).toContain('Error: expect(received).toMatchText(expected)');
|
expect(output).toContain('Error: expect(received).toHaveText(expected)');
|
||||||
expect(output).toContain('Expected pattern: /Text 2/');
|
expect(output).toContain('Expected pattern: /Text 2/');
|
||||||
expect(output).toContain('Received string: "Text content"');
|
expect(output).toContain('Received string: "Text content"');
|
||||||
expect(output).toContain('expect(handle).toMatchText');
|
expect(output).toContain('expect(locator).toHaveText');
|
||||||
expect(result.passed).toBe(1);
|
expect(result.passed).toBe(1);
|
||||||
expect(result.failed).toBe(1);
|
expect(result.failed).toBe(1);
|
||||||
expect(result.exitCode).toBe(1);
|
expect(result.exitCode).toBe(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should support toHaveText', async ({ runInlineTest }) => {
|
test('should support toHaveText w/ text', async ({ runInlineTest }) => {
|
||||||
const result = await runInlineTest({
|
const result = await runInlineTest({
|
||||||
'a.test.ts': `
|
'a.test.ts': `
|
||||||
const { test } = pwt;
|
const { test } = pwt;
|
||||||
|
|
||||||
test('pass', async ({ page }) => {
|
test('pass', async ({ page }) => {
|
||||||
await page.setContent('<div id=node>Text content</div>');
|
await page.setContent('<div id=node>Text content</div>');
|
||||||
const handle = page.locator('#node');
|
const locator = page.locator('#node');
|
||||||
await expect(handle).toHaveText('Text content');
|
await expect(locator).toHaveText('Text content');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('pass contain', async ({ page }) => {
|
||||||
|
await page.setContent('<div id=node>Text content</div>');
|
||||||
|
const locator = page.locator('#node');
|
||||||
|
await expect(locator).toContainText('Text');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('fail', async ({ page }) => {
|
test('fail', async ({ page }) => {
|
||||||
await page.setContent('<div id=node>Text content</div>');
|
await page.setContent('<div id=node>Text content</div>');
|
||||||
const handle = page.locator('#node');
|
const locator = page.locator('#node');
|
||||||
await expect(handle).toHaveText('Text', { timeout: 100 });
|
await expect(locator).toHaveText('Text', { timeout: 100 });
|
||||||
});
|
});
|
||||||
`,
|
`,
|
||||||
}, { workers: 1 });
|
}, { workers: 1 });
|
||||||
@ -66,23 +72,23 @@ test('should support toHaveText', async ({ runInlineTest }) => {
|
|||||||
expect(output).toContain('Error: expect(received).toHaveText(expected)');
|
expect(output).toContain('Error: expect(received).toHaveText(expected)');
|
||||||
expect(output).toContain('Expected string: "Text"');
|
expect(output).toContain('Expected string: "Text"');
|
||||||
expect(output).toContain('Received string: "Text content"');
|
expect(output).toContain('Received string: "Text content"');
|
||||||
expect(output).toContain('expect(handle).toHaveText');
|
expect(output).toContain('expect(locator).toHaveText');
|
||||||
expect(result.passed).toBe(1);
|
expect(result.passed).toBe(2);
|
||||||
expect(result.failed).toBe(1);
|
expect(result.failed).toBe(1);
|
||||||
expect(result.exitCode).toBe(1);
|
expect(result.exitCode).toBe(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should support toMatchText eventually', async ({ runInlineTest }) => {
|
test('should support toHaveText eventually', async ({ runInlineTest }) => {
|
||||||
const result = await runInlineTest({
|
const result = await runInlineTest({
|
||||||
'a.test.ts': `
|
'a.test.ts': `
|
||||||
const { test } = pwt;
|
const { test } = pwt;
|
||||||
|
|
||||||
test('pass eventually', async ({ page }) => {
|
test('pass eventually', async ({ page }) => {
|
||||||
await page.setContent('<div id=node>Text content</div>');
|
await page.setContent('<div id=node>Text content</div>');
|
||||||
const handle = page.locator('#node');
|
const locator = page.locator('#node');
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
expect(handle).toMatchText(/Text 2/),
|
expect(locator).toHaveText(/Text 2/),
|
||||||
page.waitForTimeout(1000).then(() => handle.evaluate(element => element.textContent = 'Text 2 content')),
|
page.waitForTimeout(1000).then(() => locator.evaluate(element => element.textContent = 'Text 2 content')),
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
`,
|
`,
|
||||||
@ -92,15 +98,15 @@ test('should support toMatchText eventually', async ({ runInlineTest }) => {
|
|||||||
expect(result.exitCode).toBe(0);
|
expect(result.exitCode).toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should support toMatchText with innerText', async ({ runInlineTest }) => {
|
test('should support toHaveText with innerText', async ({ runInlineTest }) => {
|
||||||
const result = await runInlineTest({
|
const result = await runInlineTest({
|
||||||
'a.test.ts': `
|
'a.test.ts': `
|
||||||
const { test } = pwt;
|
const { test } = pwt;
|
||||||
|
|
||||||
test('pass', async ({ page }) => {
|
test('pass', async ({ page }) => {
|
||||||
await page.setContent('<div id=node>Text content</div>');
|
await page.setContent('<div id=node>Text content</div>');
|
||||||
const handle = page.locator('#node');
|
const locator = page.locator('#node');
|
||||||
await expect(handle).toHaveText('Text content', { useInnerText: true });
|
await expect(locator).toHaveText('Text content', { useInnerText: true });
|
||||||
});
|
});
|
||||||
`,
|
`,
|
||||||
}, { workers: 1 });
|
}, { workers: 1 });
|
||||||
@ -108,3 +114,83 @@ test('should support toMatchText with innerText', async ({ runInlineTest }) => {
|
|||||||
expect(result.exitCode).toBe(0);
|
expect(result.exitCode).toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('should support toHaveAttr', async ({ runInlineTest }) => {
|
||||||
|
const result = await runInlineTest({
|
||||||
|
'a.test.ts': `
|
||||||
|
const { test } = pwt;
|
||||||
|
|
||||||
|
test('pass', async ({ page }) => {
|
||||||
|
await page.setContent('<div id=node>Text content</div>');
|
||||||
|
const locator = page.locator('#node');
|
||||||
|
await expect(locator).toHaveAttr('id', 'node');
|
||||||
|
});
|
||||||
|
`,
|
||||||
|
}, { workers: 1 });
|
||||||
|
expect(result.passed).toBe(1);
|
||||||
|
expect(result.exitCode).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should support toHaveData', async ({ runInlineTest }) => {
|
||||||
|
const result = await runInlineTest({
|
||||||
|
'a.test.ts': `
|
||||||
|
const { test } = pwt;
|
||||||
|
|
||||||
|
test('pass', async ({ page }) => {
|
||||||
|
await page.setContent('<div id=node>Text content</div>');
|
||||||
|
const locator = page.locator('#node');
|
||||||
|
await expect(locator).toHaveAttr('id', 'node');
|
||||||
|
});
|
||||||
|
`,
|
||||||
|
}, { workers: 1 });
|
||||||
|
expect(result.passed).toBe(1);
|
||||||
|
expect(result.exitCode).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should support toHaveCSS', async ({ runInlineTest }) => {
|
||||||
|
const result = await runInlineTest({
|
||||||
|
'a.test.ts': `
|
||||||
|
const { test } = pwt;
|
||||||
|
|
||||||
|
test('pass', async ({ page }) => {
|
||||||
|
await page.setContent('<div id=node style="color: rgb(255, 0, 0)">Text content</div>');
|
||||||
|
const locator = page.locator('#node');
|
||||||
|
await expect(locator).toHaveCSS('color', 'rgb(255, 0, 0)');
|
||||||
|
});
|
||||||
|
`,
|
||||||
|
}, { workers: 1 });
|
||||||
|
expect(result.passed).toBe(1);
|
||||||
|
expect(result.exitCode).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should support toHaveId', async ({ runInlineTest }) => {
|
||||||
|
const result = await runInlineTest({
|
||||||
|
'a.test.ts': `
|
||||||
|
const { test } = pwt;
|
||||||
|
|
||||||
|
test('pass', async ({ page }) => {
|
||||||
|
await page.setContent('<div id=node>Text content</div>');
|
||||||
|
const locator = page.locator('#node');
|
||||||
|
await expect(locator).toHaveId('node');
|
||||||
|
});
|
||||||
|
`,
|
||||||
|
}, { workers: 1 });
|
||||||
|
expect(result.passed).toBe(1);
|
||||||
|
expect(result.exitCode).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should support toHaveValue', async ({ runInlineTest }) => {
|
||||||
|
const result = await runInlineTest({
|
||||||
|
'a.test.ts': `
|
||||||
|
const { test } = pwt;
|
||||||
|
|
||||||
|
test('pass', async ({ page }) => {
|
||||||
|
await page.setContent('<input id=node></input>');
|
||||||
|
const locator = page.locator('#node');
|
||||||
|
await locator.fill('Text content');
|
||||||
|
await expect(locator).toHaveValue('Text content');
|
||||||
|
});
|
||||||
|
`,
|
||||||
|
}, { workers: 1 });
|
||||||
|
expect(result.passed).toBe(1);
|
||||||
|
expect(result.exitCode).toBe(0);
|
||||||
|
});
|
||||||
|
|||||||
146
tests/playwright-test/playwright.expect.true.spec.ts
Normal file
146
tests/playwright-test/playwright.expect.true.spec.ts
Normal file
@ -0,0 +1,146 @@
|
|||||||
|
/**
|
||||||
|
* Copyright (c) Microsoft Corporation.
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { test, expect, stripAscii } from './playwright-test-fixtures';
|
||||||
|
|
||||||
|
test('should support toBeChecked', async ({ runInlineTest }) => {
|
||||||
|
const result = await runInlineTest({
|
||||||
|
'a.test.ts': `
|
||||||
|
const { test } = pwt;
|
||||||
|
|
||||||
|
test('pass', async ({ page }) => {
|
||||||
|
await page.setContent('<input type=checkbox checked></input>');
|
||||||
|
const locator = page.locator('input');
|
||||||
|
await expect(locator).toBeChecked();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('pass not', async ({ page }) => {
|
||||||
|
await page.setContent('<input type=checkbox></input>');
|
||||||
|
const locator = page.locator('input');
|
||||||
|
await expect(locator).not.toBeChecked();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('fail', async ({ page }) => {
|
||||||
|
await page.setContent('<input type=checkbox></input>');
|
||||||
|
const locator = page.locator('input');
|
||||||
|
await expect(locator).toBeChecked({ timeout: 100 });
|
||||||
|
});
|
||||||
|
`,
|
||||||
|
}, { workers: 1 });
|
||||||
|
const output = stripAscii(result.output);
|
||||||
|
expect(output).toContain('Error: expect(received).toBeChecked()');
|
||||||
|
expect(output).toContain('expect(locator).toBeChecked');
|
||||||
|
expect(result.passed).toBe(2);
|
||||||
|
expect(result.failed).toBe(1);
|
||||||
|
expect(result.exitCode).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should support toBeEditable, toBeEnabled, toBeDisabled, toBeEmpty', async ({ runInlineTest }) => {
|
||||||
|
const result = await runInlineTest({
|
||||||
|
'a.test.ts': `
|
||||||
|
const { test } = pwt;
|
||||||
|
|
||||||
|
test('editable', async ({ page }) => {
|
||||||
|
await page.setContent('<input></input>');
|
||||||
|
const locator = page.locator('input');
|
||||||
|
await expect(locator).toBeEditable();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('enabled', async ({ page }) => {
|
||||||
|
await page.setContent('<button>Text</button>');
|
||||||
|
const locator = page.locator('button');
|
||||||
|
await expect(locator).toBeEnabled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('disabled', async ({ page }) => {
|
||||||
|
await page.setContent('<button disabled>Text</button>');
|
||||||
|
const locator = page.locator('button');
|
||||||
|
await expect(locator).toBeDisabled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('empty input', async ({ page }) => {
|
||||||
|
await page.setContent('<input></inpput>');
|
||||||
|
const locator = page.locator('input');
|
||||||
|
await expect(locator).toBeEmpty();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('non-empty input', async ({ page }) => {
|
||||||
|
await page.setContent('<input value=text></input>');
|
||||||
|
const locator = page.locator('input');
|
||||||
|
await expect(locator).not.toBeEmpty();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('empty DOM', async ({ page }) => {
|
||||||
|
await page.setContent('<div style="width: 50; height: 50px"></div>');
|
||||||
|
const locator = page.locator('div');
|
||||||
|
await expect(locator).toBeEmpty();
|
||||||
|
});
|
||||||
|
`,
|
||||||
|
}, { workers: 1 });
|
||||||
|
expect(result.passed).toBe(6);
|
||||||
|
expect(result.exitCode).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should support toBeVisible, toBeHidden', async ({ runInlineTest }) => {
|
||||||
|
const result = await runInlineTest({
|
||||||
|
'a.test.ts': `
|
||||||
|
const { test } = pwt;
|
||||||
|
|
||||||
|
test('visible', async ({ page }) => {
|
||||||
|
await page.setContent('<input></input>');
|
||||||
|
const locator = page.locator('input');
|
||||||
|
await expect(locator).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('not visible', async ({ page }) => {
|
||||||
|
await page.setContent('<button style="display: none"></button>');
|
||||||
|
const locator = page.locator('button');
|
||||||
|
await expect(locator).not.toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('hidden', async ({ page }) => {
|
||||||
|
await page.setContent('<button style="display: none"></button>');
|
||||||
|
const locator = page.locator('button');
|
||||||
|
await expect(locator).toBeHidden();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('not hidden', async ({ page }) => {
|
||||||
|
await page.setContent('<input></input>');
|
||||||
|
const locator = page.locator('input');
|
||||||
|
await expect(locator).not.toBeHidden();
|
||||||
|
});
|
||||||
|
`,
|
||||||
|
}, { workers: 1 });
|
||||||
|
expect(result.passed).toBe(4);
|
||||||
|
expect(result.exitCode).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should support toBeFocused', async ({ runInlineTest }) => {
|
||||||
|
const result = await runInlineTest({
|
||||||
|
'a.test.ts': `
|
||||||
|
const { test } = pwt;
|
||||||
|
|
||||||
|
test('focused', async ({ page }) => {
|
||||||
|
await page.setContent('<input></input>');
|
||||||
|
const locator = page.locator('input');
|
||||||
|
await locator.focus();
|
||||||
|
await expect(locator).toBeFocused({ timeout: 1000 });
|
||||||
|
});
|
||||||
|
`,
|
||||||
|
}, { workers: 1 });
|
||||||
|
expect(result.passed).toBe(1);
|
||||||
|
expect(result.exitCode).toBe(0);
|
||||||
|
});
|
||||||
69
types/testExpect.d.ts
vendored
69
types/testExpect.d.ts
vendored
@ -72,12 +72,77 @@ declare global {
|
|||||||
/**
|
/**
|
||||||
* Asserts element's exact text content.
|
* Asserts element's exact text content.
|
||||||
*/
|
*/
|
||||||
toHaveText(expected: string, options?: { timeout?: number, useInnerText?: boolean }): Promise<R>;
|
toHaveText(expected: string | RegExp, options?: { timeout?: number, useInnerText?: boolean }): Promise<R>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Asserts element's text content matches given pattern or contains given substring.
|
* Asserts element's text content matches given pattern or contains given substring.
|
||||||
*/
|
*/
|
||||||
toMatchText(expected: string | RegExp, options?: { timeout?: number, useInnerText?: boolean }): Promise<R>;
|
toContainText(expected: string, options?: { timeout?: number, useInnerText?: boolean }): Promise<R>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Asserts element's attributes `name` matches expected value.
|
||||||
|
*/
|
||||||
|
toHaveAttr(expected: string | RegExp, name: string, options?: { timeout?: number }): Promise<R>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Asserts element's data attribute data-`name` matches expected value.
|
||||||
|
*/
|
||||||
|
toHaveData(expected: string | RegExp, name: string, options?: { timeout?: number }): Promise<R>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Asserts element's computed CSS property `name` matches expected value.
|
||||||
|
*/
|
||||||
|
toHaveCSS(expected: string | RegExp, name: string, options?: { timeout?: number }): Promise<R>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Asserts element's `id` attribute matches expected value.
|
||||||
|
*/
|
||||||
|
toHaveId(expected: string | RegExp, options?: { timeout?: number }): Promise<R>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Asserts input element's value.
|
||||||
|
*/
|
||||||
|
toHaveValue(expected: string | RegExp, options?: { timeout?: number }): Promise<R>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Asserts input is checked.
|
||||||
|
*/
|
||||||
|
toBeChecked(options?: { timeout?: number }): Promise<R>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Asserts input is editable.
|
||||||
|
*/
|
||||||
|
toBeEditable(options?: { timeout?: number }): Promise<R>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Asserts input is enabled.
|
||||||
|
*/
|
||||||
|
toBeEnabled(options?: { timeout?: number }): Promise<R>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Asserts input is disabled.
|
||||||
|
*/
|
||||||
|
toBeDisabled(options?: { timeout?: number }): Promise<R>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Asserts given DOM node or input has no text content or no input value.
|
||||||
|
*/
|
||||||
|
toBeEmpty(options?: { timeout?: number }): Promise<R>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Asserts given DOM node is hidden or detached from DOM.
|
||||||
|
*/
|
||||||
|
toBeHidden(options?: { timeout?: number }): Promise<R>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Asserts given DOM node visible on the screen.
|
||||||
|
*/
|
||||||
|
toBeVisible(options?: { timeout?: number }): Promise<R>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Asserts given DOM is a focused (active) in document.
|
||||||
|
*/
|
||||||
|
toBeFocused(options?: { timeout?: number }): Promise<R>;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
33
types/types.d.ts
vendored
33
types/types.d.ts
vendored
@ -6968,18 +6968,6 @@ export interface ElementHandle<T=Node> extends JSHandle<T> {
|
|||||||
*
|
*
|
||||||
*/
|
*/
|
||||||
export interface Locator {
|
export interface Locator {
|
||||||
/**
|
|
||||||
* Resolves given locator to the first matching DOM element. If no elements matching the query are visible, waits for them
|
|
||||||
* up to a given timeout. If multiple elements match the selector, throws.
|
|
||||||
* @param options
|
|
||||||
*/
|
|
||||||
elementHandle(options?: {
|
|
||||||
timeout?: number;
|
|
||||||
}): Promise<null|ElementHandle<SVGElement | HTMLElement>>;
|
|
||||||
/**
|
|
||||||
* Resolves given locator to all matching DOM elements.
|
|
||||||
*/
|
|
||||||
elementHandles(): Promise<null|ElementHandle<SVGElement | HTMLElement>[]>;
|
|
||||||
/**
|
/**
|
||||||
* Returns the return value of `pageFunction`.
|
* Returns the return value of `pageFunction`.
|
||||||
*
|
*
|
||||||
@ -6999,8 +6987,12 @@ export interface Locator {
|
|||||||
* @param arg Optional argument to pass to `pageFunction`.
|
* @param arg Optional argument to pass to `pageFunction`.
|
||||||
* @param options
|
* @param options
|
||||||
*/
|
*/
|
||||||
evaluate<R, Arg>(pageFunction: PageFunctionOn<SVGElement | HTMLElement, Arg, R>, arg: Arg): Promise<R>;
|
evaluate<R, Arg>(pageFunction: PageFunctionOn<SVGElement | HTMLElement, Arg, R>, arg: Arg, options?: {
|
||||||
evaluate<R>(pageFunction: PageFunctionOn<SVGElement | HTMLElement, void, R>): Promise<R>;
|
timeout?: number;
|
||||||
|
}): Promise<R>;
|
||||||
|
evaluate<R>(pageFunction: PageFunctionOn<SVGElement | HTMLElement, void, R>, options?: {
|
||||||
|
timeout?: number;
|
||||||
|
}): Promise<R>;
|
||||||
/**
|
/**
|
||||||
* The method finds all elements matching the specified locator and passes an array of matched elements as a first argument
|
* The method finds all elements matching the specified locator and passes an array of matched elements as a first argument
|
||||||
* to `pageFunction`. Returns the result of `pageFunction` invocation.
|
* to `pageFunction`. Returns the result of `pageFunction` invocation.
|
||||||
@ -7020,6 +7012,14 @@ export interface Locator {
|
|||||||
*/
|
*/
|
||||||
evaluateAll<R, Arg>(pageFunction: PageFunctionOn<(SVGElement | HTMLElement)[], Arg, R>, arg: Arg): Promise<R>;
|
evaluateAll<R, Arg>(pageFunction: PageFunctionOn<(SVGElement | HTMLElement)[], Arg, R>, arg: Arg): Promise<R>;
|
||||||
evaluateAll<R>(pageFunction: PageFunctionOn<(SVGElement | HTMLElement)[], void, R>): Promise<R>;
|
evaluateAll<R>(pageFunction: PageFunctionOn<(SVGElement | HTMLElement)[], void, R>): Promise<R>;
|
||||||
|
/**
|
||||||
|
* Resolves given locator to the first matching DOM element. If no elements matching the query are visible, waits for them
|
||||||
|
* up to a given timeout. If multiple elements match the selector, throws.
|
||||||
|
* @param options
|
||||||
|
*/
|
||||||
|
elementHandle(options?: {
|
||||||
|
timeout?: number;
|
||||||
|
}): Promise<null|ElementHandle<SVGElement | HTMLElement>>;
|
||||||
/**
|
/**
|
||||||
* This method returns the bounding box of the element, or `null` if the element is not visible. The bounding box is
|
* This method returns the bounding box of the element, or `null` if the element is not visible. The bounding box is
|
||||||
* calculated relative to the main frame viewport - which is usually the same as the browser window.
|
* calculated relative to the main frame viewport - which is usually the same as the browser window.
|
||||||
@ -7317,6 +7317,11 @@ export interface Locator {
|
|||||||
timeout?: number;
|
timeout?: number;
|
||||||
}): Promise<void>;
|
}): Promise<void>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves given locator to all matching DOM elements.
|
||||||
|
*/
|
||||||
|
elementHandles(): Promise<Array<ElementHandle>>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns the return value of `pageFunction` as a [JSHandle].
|
* Returns the return value of `pageFunction` as a [JSHandle].
|
||||||
*
|
*
|
||||||
|
|||||||
13
utils/generate_types/overrides.d.ts
vendored
13
utils/generate_types/overrides.d.ts
vendored
@ -141,14 +141,17 @@ export interface ElementHandle<T=Node> extends JSHandle<T> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface Locator {
|
export interface Locator {
|
||||||
|
evaluate<R, Arg>(pageFunction: PageFunctionOn<SVGElement | HTMLElement, Arg, R>, arg: Arg, options?: {
|
||||||
|
timeout?: number;
|
||||||
|
}): Promise<R>;
|
||||||
|
evaluate<R>(pageFunction: PageFunctionOn<SVGElement | HTMLElement, void, R>, options?: {
|
||||||
|
timeout?: number;
|
||||||
|
}): Promise<R>;
|
||||||
|
evaluateAll<R, Arg>(pageFunction: PageFunctionOn<(SVGElement | HTMLElement)[], Arg, R>, arg: Arg): Promise<R>;
|
||||||
|
evaluateAll<R>(pageFunction: PageFunctionOn<(SVGElement | HTMLElement)[], void, R>): Promise<R>;
|
||||||
elementHandle(options?: {
|
elementHandle(options?: {
|
||||||
timeout?: number;
|
timeout?: number;
|
||||||
}): Promise<null|ElementHandle<SVGElement | HTMLElement>>;
|
}): Promise<null|ElementHandle<SVGElement | HTMLElement>>;
|
||||||
elementHandles(): Promise<null|ElementHandle<SVGElement | HTMLElement>[]>;
|
|
||||||
evaluate<R, Arg>(pageFunction: PageFunctionOn<SVGElement | HTMLElement, Arg, R>, arg: Arg): Promise<R>;
|
|
||||||
evaluate<R>(pageFunction: PageFunctionOn<SVGElement | HTMLElement, void, R>): Promise<R>;
|
|
||||||
evaluateAll<R, Arg>(pageFunction: PageFunctionOn<(SVGElement | HTMLElement)[], Arg, R>, arg: Arg): Promise<R>;
|
|
||||||
evaluateAll<R>(pageFunction: PageFunctionOn<(SVGElement | HTMLElement)[], void, R>): Promise<R>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BrowserType<Unused = {}> {
|
export interface BrowserType<Unused = {}> {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user