mirror of
https://github.com/microsoft/playwright.git
synced 2024-12-15 06:02:57 +03:00
feature(waitFor): use URLMatch to match request/response, waitForEvent for generic (#278)
This commit is contained in:
parent
0dafabb05e
commit
75ae9bfeee
@ -16,6 +16,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import * as debug from 'debug';
|
import * as debug from 'debug';
|
||||||
|
import * as types from './types';
|
||||||
import { TimeoutError } from './errors';
|
import { TimeoutError } from './errors';
|
||||||
|
|
||||||
export const debugError = debug(`playwright:error`);
|
export const debugError = debug(`playwright:error`);
|
||||||
@ -114,9 +115,13 @@ class Helper {
|
|||||||
rejectCallback = reject;
|
rejectCallback = reject;
|
||||||
});
|
});
|
||||||
const listener = Helper.addEventListener(emitter, eventName, event => {
|
const listener = Helper.addEventListener(emitter, eventName, event => {
|
||||||
if (!predicate(event))
|
try {
|
||||||
return;
|
if (!predicate(event))
|
||||||
resolveCallback(event);
|
return;
|
||||||
|
resolveCallback(event);
|
||||||
|
} catch (e) {
|
||||||
|
rejectCallback(e);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
if (timeout) {
|
if (timeout) {
|
||||||
eventTimeout = setTimeout(() => {
|
eventTimeout = setTimeout(() => {
|
||||||
@ -153,6 +158,93 @@ class Helper {
|
|||||||
clearTimeout(timeoutTimer);
|
clearTimeout(timeoutTimer);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static stringMatches(s: string, match: string | RegExp, name: string): boolean {
|
||||||
|
if (helper.isString(match))
|
||||||
|
return s === match;
|
||||||
|
if (match instanceof RegExp)
|
||||||
|
return match.test(s);
|
||||||
|
throw new Error(`url match field "${name}" must be a string or a RegExp, got ${typeof match}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
static searchParamsMatch(params: URLSearchParams, match: types.SearchParamsMatch, strict: boolean, name: string): boolean {
|
||||||
|
if (typeof match !== 'object' || match === null)
|
||||||
|
throw new Error(`url match field "${name}" must be an object, got ${typeof match}`);
|
||||||
|
const keys = new Set((params as any).keys()) as Set<string>;
|
||||||
|
if (strict && keys.size !== Object.keys(match).length)
|
||||||
|
return false;
|
||||||
|
for (const key of keys) {
|
||||||
|
let expected = [];
|
||||||
|
if (key in match) {
|
||||||
|
let keyMatch = match[key];
|
||||||
|
if (!Array.isArray(keyMatch))
|
||||||
|
keyMatch = [keyMatch];
|
||||||
|
expected = keyMatch;
|
||||||
|
} else if (!strict) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const values = params.getAll(key);
|
||||||
|
if (strict && values.length !== expected.length)
|
||||||
|
return false;
|
||||||
|
for (const v of values) {
|
||||||
|
let found = false;
|
||||||
|
for (const e of expected) {
|
||||||
|
if (helper.stringMatches(v, e, name + '.' + key)) {
|
||||||
|
found = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!found)
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
static urlMatches(urlString: string, match: types.URLMatch): boolean {
|
||||||
|
let url;
|
||||||
|
try {
|
||||||
|
url = new URL(urlString);
|
||||||
|
} catch (e) {
|
||||||
|
return urlString === match.url &&
|
||||||
|
match.hash === undefined &&
|
||||||
|
match.host === undefined &&
|
||||||
|
match.hostname === undefined &&
|
||||||
|
match.origin === undefined &&
|
||||||
|
match.password === undefined &&
|
||||||
|
match.pathname === undefined &&
|
||||||
|
match.port === undefined &&
|
||||||
|
match.protocol === undefined &&
|
||||||
|
match.search === undefined &&
|
||||||
|
match.searchParams === undefined &&
|
||||||
|
match.username === undefined;
|
||||||
|
}
|
||||||
|
if (match.url !== undefined && !helper.stringMatches(urlString, match.url, 'url'))
|
||||||
|
return false;
|
||||||
|
if (match.hash !== undefined && !helper.stringMatches(url.hash, match.hash, 'hash'))
|
||||||
|
return false;
|
||||||
|
if (match.host !== undefined && !helper.stringMatches(url.host, match.host, 'host'))
|
||||||
|
return false;
|
||||||
|
if (match.hostname !== undefined && !helper.stringMatches(url.hostname, match.hostname, 'hostname'))
|
||||||
|
return false;
|
||||||
|
if (match.origin !== undefined && !helper.stringMatches(url.origin, match.origin, 'origin'))
|
||||||
|
return false;
|
||||||
|
if (match.password !== undefined && !helper.stringMatches(url.password, match.password, 'password'))
|
||||||
|
return false;
|
||||||
|
if (match.pathname !== undefined && !helper.stringMatches(url.pathname, match.pathname, 'pathname'))
|
||||||
|
return false;
|
||||||
|
if (match.port !== undefined && !helper.stringMatches(url.port, match.port, 'port'))
|
||||||
|
return false;
|
||||||
|
if (match.protocol !== undefined && !helper.stringMatches(url.protocol, match.protocol, 'protocol'))
|
||||||
|
return false;
|
||||||
|
if (match.search !== undefined && !helper.stringMatches(url.search, match.search, 'search'))
|
||||||
|
return false;
|
||||||
|
if (match.username !== undefined && !helper.stringMatches(url.username, match.username, 'username'))
|
||||||
|
return false;
|
||||||
|
if (match.searchParams !== undefined && !helper.searchParamsMatch(url.searchParams, match.searchParams, !!match.strictSearchParams, 'searchParams'))
|
||||||
|
return false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function assert(value: any, message?: string) {
|
export function assert(value: any, message?: string) {
|
||||||
|
41
src/page.ts
41
src/page.ts
@ -151,10 +151,8 @@ export class Page extends EventEmitter {
|
|||||||
this.emit(Events.Page.FileChooser, fileChooser);
|
this.emit(Events.Page.FileChooser, fileChooser);
|
||||||
}
|
}
|
||||||
|
|
||||||
async waitForFileChooser(options: { timeout?: number; } = {}): Promise<FileChooser> {
|
async waitForFileChooser(options: types.TimeoutOptions = {}): Promise<FileChooser> {
|
||||||
const {
|
const { timeout = this._timeoutSettings.timeout() } = options;
|
||||||
timeout = this._timeoutSettings.timeout(),
|
|
||||||
} = options;
|
|
||||||
let callback;
|
let callback;
|
||||||
const promise = new Promise<FileChooser>(x => callback = x);
|
const promise = new Promise<FileChooser>(x => callback = x);
|
||||||
this._fileChooserInterceptors.add(callback);
|
this._fileChooserInterceptors.add(callback);
|
||||||
@ -333,29 +331,28 @@ export class Page extends EventEmitter {
|
|||||||
return this.mainFrame().waitForNavigation(options);
|
return this.mainFrame().waitForNavigation(options);
|
||||||
}
|
}
|
||||||
|
|
||||||
async waitForRequest(urlOrPredicate: (string | Function), options: { timeout?: number; } = {}): Promise<Request> {
|
async waitForEvent(event: string, options: Function | (types.TimeoutOptions & { predicate?: Function }) = {}): Promise<any> {
|
||||||
const {
|
if (typeof options === 'function')
|
||||||
timeout = this._timeoutSettings.timeout(),
|
options = { predicate: options };
|
||||||
} = options;
|
const { timeout = this._timeoutSettings.timeout(), predicate = () => true } = options;
|
||||||
|
return helper.waitForEvent(this, event, (...args: any[]) => !!predicate(...args), timeout, this._disconnectedPromise);
|
||||||
|
}
|
||||||
|
|
||||||
|
async waitForRequest(options: string | (types.URLMatch & types.TimeoutOptions) = {}): Promise<Request> {
|
||||||
|
if (helper.isString(options))
|
||||||
|
options = { url: options };
|
||||||
|
const { timeout = this._timeoutSettings.timeout() } = options;
|
||||||
return helper.waitForEvent(this, Events.Page.Request, (request: network.Request) => {
|
return helper.waitForEvent(this, Events.Page.Request, (request: network.Request) => {
|
||||||
if (helper.isString(urlOrPredicate))
|
return helper.urlMatches(request.url(), options as types.URLMatch);
|
||||||
return (urlOrPredicate === request.url());
|
|
||||||
if (typeof urlOrPredicate === 'function')
|
|
||||||
return !!(urlOrPredicate(request));
|
|
||||||
return false;
|
|
||||||
}, timeout, this._disconnectedPromise);
|
}, timeout, this._disconnectedPromise);
|
||||||
}
|
}
|
||||||
|
|
||||||
async waitForResponse(urlOrPredicate: (string | Function), options: { timeout?: number; } = {}): Promise<network.Response> {
|
async waitForResponse(options: string | (types.URLMatch & types.TimeoutOptions) = {}): Promise<Request> {
|
||||||
const {
|
if (helper.isString(options))
|
||||||
timeout = this._timeoutSettings.timeout(),
|
options = { url: options };
|
||||||
} = options;
|
const { timeout = this._timeoutSettings.timeout() } = options;
|
||||||
return helper.waitForEvent(this, Events.Page.Response, (response: network.Response) => {
|
return helper.waitForEvent(this, Events.Page.Response, (response: network.Response) => {
|
||||||
if (helper.isString(urlOrPredicate))
|
return helper.urlMatches(response.url(), options as types.URLMatch);
|
||||||
return (urlOrPredicate === response.url());
|
|
||||||
if (typeof urlOrPredicate === 'function')
|
|
||||||
return !!(urlOrPredicate(response));
|
|
||||||
return false;
|
|
||||||
}, timeout, this._disconnectedPromise);
|
}, timeout, this._disconnectedPromise);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
17
src/types.ts
17
src/types.ts
@ -72,3 +72,20 @@ export type Viewport = {
|
|||||||
isLandscape?: boolean;
|
isLandscape?: boolean;
|
||||||
hasTouch?: boolean;
|
hasTouch?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type SearchParamsMatch = { [key: string]: string | RegExp | (string | RegExp)[] };
|
||||||
|
export type URLMatch = {
|
||||||
|
url?: string | RegExp,
|
||||||
|
hash?: string | RegExp,
|
||||||
|
host?: string | RegExp,
|
||||||
|
hostname?: string | RegExp,
|
||||||
|
origin?: string | RegExp,
|
||||||
|
password?: string | RegExp,
|
||||||
|
pathname?: string | RegExp,
|
||||||
|
port?: string | RegExp,
|
||||||
|
protocol?: string | RegExp,
|
||||||
|
search?: string | RegExp,
|
||||||
|
strictSearchParams?: boolean,
|
||||||
|
searchParams?: SearchParamsMatch,
|
||||||
|
username?: string | RegExp,
|
||||||
|
};
|
||||||
|
@ -152,7 +152,7 @@ module.exports.addTests = function({testRunner, expect, FFOX, CHROME, WEBKIT}) {
|
|||||||
page.on('requestfinished', r => requestFinished = requestFinished || r.url().includes('/get'));
|
page.on('requestfinished', r => requestFinished = requestFinished || r.url().includes('/get'));
|
||||||
// send request and wait for server response
|
// send request and wait for server response
|
||||||
const [pageResponse] = await Promise.all([
|
const [pageResponse] = await Promise.all([
|
||||||
page.waitForResponse(r => !utils.isFavicon(r.request())),
|
page.waitForEvent('response', { predicate: r => !utils.isFavicon(r.request()) }),
|
||||||
page.evaluate(() => fetch('./get', { method: 'GET'})),
|
page.evaluate(() => fetch('./get', { method: 'GET'})),
|
||||||
server.waitForRequest('/get'),
|
server.waitForRequest('/get'),
|
||||||
]);
|
]);
|
||||||
|
@ -299,7 +299,7 @@ module.exports.addTests = function({testRunner, expect, headless, playwright, FF
|
|||||||
it('should work with predicate', async({page, server}) => {
|
it('should work with predicate', async({page, server}) => {
|
||||||
await page.goto(server.EMPTY_PAGE);
|
await page.goto(server.EMPTY_PAGE);
|
||||||
const [request] = await Promise.all([
|
const [request] = await Promise.all([
|
||||||
page.waitForRequest(request => request.url() === server.PREFIX + '/digits/2.png'),
|
page.waitForEvent('request', request => request.url() === server.PREFIX + '/digits/2.png'),
|
||||||
page.evaluate(() => {
|
page.evaluate(() => {
|
||||||
fetch('/digits/1.png');
|
fetch('/digits/1.png');
|
||||||
fetch('/digits/2.png');
|
fetch('/digits/2.png');
|
||||||
@ -310,19 +310,19 @@ module.exports.addTests = function({testRunner, expect, headless, playwright, FF
|
|||||||
});
|
});
|
||||||
it('should respect timeout', async({page, server}) => {
|
it('should respect timeout', async({page, server}) => {
|
||||||
let error = null;
|
let error = null;
|
||||||
await page.waitForRequest(() => false, {timeout: 1}).catch(e => error = e);
|
await page.waitForEvent('request', { predicate: () => false, timeout: 1 }).catch(e => error = e);
|
||||||
expect(error).toBeInstanceOf(playwright.errors.TimeoutError);
|
expect(error).toBeInstanceOf(playwright.errors.TimeoutError);
|
||||||
});
|
});
|
||||||
it('should respect default timeout', async({page, server}) => {
|
it('should respect default timeout', async({page, server}) => {
|
||||||
let error = null;
|
let error = null;
|
||||||
page.setDefaultTimeout(1);
|
page.setDefaultTimeout(1);
|
||||||
await page.waitForRequest(() => false).catch(e => error = e);
|
await page.waitForEvent('request', () => false).catch(e => error = e);
|
||||||
expect(error).toBeInstanceOf(playwright.errors.TimeoutError);
|
expect(error).toBeInstanceOf(playwright.errors.TimeoutError);
|
||||||
});
|
});
|
||||||
it('should work with no timeout', async({page, server}) => {
|
it('should work with no timeout', async({page, server}) => {
|
||||||
await page.goto(server.EMPTY_PAGE);
|
await page.goto(server.EMPTY_PAGE);
|
||||||
const [request] = await Promise.all([
|
const [request] = await Promise.all([
|
||||||
page.waitForRequest(server.PREFIX + '/digits/2.png', {timeout: 0}),
|
page.waitForRequest({url: server.PREFIX + '/digits/2.png', timeout: 0}),
|
||||||
page.evaluate(() => setTimeout(() => {
|
page.evaluate(() => setTimeout(() => {
|
||||||
fetch('/digits/1.png');
|
fetch('/digits/1.png');
|
||||||
fetch('/digits/2.png');
|
fetch('/digits/2.png');
|
||||||
@ -331,6 +331,84 @@ module.exports.addTests = function({testRunner, expect, headless, playwright, FF
|
|||||||
]);
|
]);
|
||||||
expect(request.url()).toBe(server.PREFIX + '/digits/2.png');
|
expect(request.url()).toBe(server.PREFIX + '/digits/2.png');
|
||||||
});
|
});
|
||||||
|
it('should work with url match', async({page, server}) => {
|
||||||
|
await page.goto(server.EMPTY_PAGE);
|
||||||
|
const [request] = await Promise.all([
|
||||||
|
page.waitForRequest({ url: /digits\/\d\.png/ }),
|
||||||
|
page.evaluate(() => {
|
||||||
|
fetch('/digits/1.png');
|
||||||
|
})
|
||||||
|
]);
|
||||||
|
expect(request.url()).toBe(server.PREFIX + '/digits/1.png');
|
||||||
|
});
|
||||||
|
it('should work with pathname match', async({page, server}) => {
|
||||||
|
await page.goto(server.EMPTY_PAGE);
|
||||||
|
const [request] = await Promise.all([
|
||||||
|
page.waitForRequest({ pathname: '/digits/2.png' }),
|
||||||
|
page.evaluate(() => {
|
||||||
|
fetch('/digits/1.png');
|
||||||
|
fetch('/digits/2.png');
|
||||||
|
fetch('/digits/3.png');
|
||||||
|
})
|
||||||
|
]);
|
||||||
|
expect(request.url()).toBe(server.PREFIX + '/digits/2.png');
|
||||||
|
});
|
||||||
|
it('should work with multiple matches', async({page, server}) => {
|
||||||
|
await page.goto(server.EMPTY_PAGE);
|
||||||
|
const [request] = await Promise.all([
|
||||||
|
page.waitForRequest({ pathname: '/digits/2.png', url: /\d\.png/, port: String(server.PORT) }),
|
||||||
|
page.evaluate(() => {
|
||||||
|
fetch('/digits/1.png');
|
||||||
|
fetch('/digits/2.png');
|
||||||
|
fetch('/digits/3.png');
|
||||||
|
})
|
||||||
|
]);
|
||||||
|
expect(request.url()).toBe(server.PREFIX + '/digits/2.png');
|
||||||
|
});
|
||||||
|
it('should work with strict search params match', async({page, server}) => {
|
||||||
|
await page.goto(server.EMPTY_PAGE);
|
||||||
|
const [request] = await Promise.all([
|
||||||
|
page.waitForRequest({ searchParams: { 'foo': [/^baz$/, 'bar'], 'bar': 'foo' }, strictSearchParams: true }),
|
||||||
|
page.evaluate(() => {
|
||||||
|
fetch('/digits/2.png?foo=bar&foo=baz&bar=foo&key=value');
|
||||||
|
fetch('/digits/1.png?foo=bar&bar=foo');
|
||||||
|
fetch('/digits/4.png?foo=bar&bar=foo&foo=baz');
|
||||||
|
fetch('/digits/3.png?bar=foo');
|
||||||
|
})
|
||||||
|
]);
|
||||||
|
expect(request.url()).toBe(server.PREFIX + '/digits/4.png?foo=bar&bar=foo&foo=baz');
|
||||||
|
});
|
||||||
|
it('should work with relaxed search params match', async({page, server}) => {
|
||||||
|
await page.goto(server.EMPTY_PAGE);
|
||||||
|
const [request] = await Promise.all([
|
||||||
|
page.waitForRequest({ searchParams: { 'foo': ['bar', /^baz$/], 'bar': 'foo' } }),
|
||||||
|
page.evaluate(() => {
|
||||||
|
fetch('/digits/1.png?key=value&foo=something');
|
||||||
|
fetch('/digits/2.png?foo=baz');
|
||||||
|
})
|
||||||
|
]);
|
||||||
|
expect(request.url()).toBe(server.PREFIX + '/digits/2.png?foo=baz');
|
||||||
|
});
|
||||||
|
it('should throw for incorrect match', async({page, server}) => {
|
||||||
|
await page.goto(server.EMPTY_PAGE);
|
||||||
|
const [error] = await Promise.all([
|
||||||
|
page.waitForRequest({ url: null }).catch(e => e),
|
||||||
|
page.evaluate(() => {
|
||||||
|
fetch('/digits/1.png');
|
||||||
|
})
|
||||||
|
]);
|
||||||
|
expect(error.message).toBe('url match field "url" must be a string or a RegExp, got object');
|
||||||
|
});
|
||||||
|
it('should throw for incorrect searchParams match', async({page, server}) => {
|
||||||
|
await page.goto(server.EMPTY_PAGE);
|
||||||
|
const [error] = await Promise.all([
|
||||||
|
page.waitForRequest({ searchParams: { 'foo': 123 } }).catch(e => e),
|
||||||
|
page.evaluate(() => {
|
||||||
|
fetch('/digits/1.png?foo=bar');
|
||||||
|
})
|
||||||
|
]);
|
||||||
|
expect(error.message).toBe('url match field "searchParams.foo" must be a string or a RegExp, got number');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('Page.waitForResponse', function() {
|
describe('Page.waitForResponse', function() {
|
||||||
@ -348,19 +426,19 @@ module.exports.addTests = function({testRunner, expect, headless, playwright, FF
|
|||||||
});
|
});
|
||||||
it('should respect timeout', async({page, server}) => {
|
it('should respect timeout', async({page, server}) => {
|
||||||
let error = null;
|
let error = null;
|
||||||
await page.waitForResponse(() => false, {timeout: 1}).catch(e => error = e);
|
await page.waitForEvent('response', { predicate: () => false, timeout: 1 }).catch(e => error = e);
|
||||||
expect(error).toBeInstanceOf(playwright.errors.TimeoutError);
|
expect(error).toBeInstanceOf(playwright.errors.TimeoutError);
|
||||||
});
|
});
|
||||||
it('should respect default timeout', async({page, server}) => {
|
it('should respect default timeout', async({page, server}) => {
|
||||||
let error = null;
|
let error = null;
|
||||||
page.setDefaultTimeout(1);
|
page.setDefaultTimeout(1);
|
||||||
await page.waitForResponse(() => false).catch(e => error = e);
|
await page.waitForEvent('response', () => false).catch(e => error = e);
|
||||||
expect(error).toBeInstanceOf(playwright.errors.TimeoutError);
|
expect(error).toBeInstanceOf(playwright.errors.TimeoutError);
|
||||||
});
|
});
|
||||||
it('should work with predicate', async({page, server}) => {
|
it('should work with predicate', async({page, server}) => {
|
||||||
await page.goto(server.EMPTY_PAGE);
|
await page.goto(server.EMPTY_PAGE);
|
||||||
const [response] = await Promise.all([
|
const [response] = await Promise.all([
|
||||||
page.waitForResponse(response => response.url() === server.PREFIX + '/digits/2.png'),
|
page.waitForEvent('response', response => response.url() === server.PREFIX + '/digits/2.png'),
|
||||||
page.evaluate(() => {
|
page.evaluate(() => {
|
||||||
fetch('/digits/1.png');
|
fetch('/digits/1.png');
|
||||||
fetch('/digits/2.png');
|
fetch('/digits/2.png');
|
||||||
@ -372,7 +450,7 @@ module.exports.addTests = function({testRunner, expect, headless, playwright, FF
|
|||||||
it('should work with no timeout', async({page, server}) => {
|
it('should work with no timeout', async({page, server}) => {
|
||||||
await page.goto(server.EMPTY_PAGE);
|
await page.goto(server.EMPTY_PAGE);
|
||||||
const [response] = await Promise.all([
|
const [response] = await Promise.all([
|
||||||
page.waitForResponse(server.PREFIX + '/digits/2.png', {timeout: 0}),
|
page.waitForResponse({ url: server.PREFIX + '/digits/2.png', timeout: 0 }),
|
||||||
page.evaluate(() => setTimeout(() => {
|
page.evaluate(() => setTimeout(() => {
|
||||||
fetch('/digits/1.png');
|
fetch('/digits/1.png');
|
||||||
fetch('/digits/2.png');
|
fetch('/digits/2.png');
|
||||||
@ -381,6 +459,18 @@ module.exports.addTests = function({testRunner, expect, headless, playwright, FF
|
|||||||
]);
|
]);
|
||||||
expect(response.url()).toBe(server.PREFIX + '/digits/2.png');
|
expect(response.url()).toBe(server.PREFIX + '/digits/2.png');
|
||||||
});
|
});
|
||||||
|
it('should work with multiple matches', async({page, server}) => {
|
||||||
|
await page.goto(server.EMPTY_PAGE);
|
||||||
|
const [response] = await Promise.all([
|
||||||
|
page.waitForResponse({ pathname: '/digits/2.png', url: /\d\.png/, port: String(server.PORT) }),
|
||||||
|
page.evaluate(() => {
|
||||||
|
fetch('/digits/1.png');
|
||||||
|
fetch('/digits/2.png');
|
||||||
|
fetch('/digits/3.png');
|
||||||
|
})
|
||||||
|
]);
|
||||||
|
expect(response.url()).toBe(server.PREFIX + '/digits/2.png');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('Page.exposeFunction', function() {
|
describe('Page.exposeFunction', function() {
|
||||||
|
Loading…
Reference in New Issue
Block a user