diff --git a/src/helper.ts b/src/helper.ts index 21052db77b..e39e9b0c92 100644 --- a/src/helper.ts +++ b/src/helper.ts @@ -16,6 +16,7 @@ */ import * as debug from 'debug'; +import * as types from './types'; import { TimeoutError } from './errors'; export const debugError = debug(`playwright:error`); @@ -114,9 +115,13 @@ class Helper { rejectCallback = reject; }); const listener = Helper.addEventListener(emitter, eventName, event => { - if (!predicate(event)) - return; - resolveCallback(event); + try { + if (!predicate(event)) + return; + resolveCallback(event); + } catch (e) { + rejectCallback(e); + } }); if (timeout) { eventTimeout = setTimeout(() => { @@ -153,6 +158,93 @@ class Helper { 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; + 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) { diff --git a/src/page.ts b/src/page.ts index 9e85bdca35..2d1dbe46e5 100644 --- a/src/page.ts +++ b/src/page.ts @@ -151,10 +151,8 @@ export class Page extends EventEmitter { this.emit(Events.Page.FileChooser, fileChooser); } - async waitForFileChooser(options: { timeout?: number; } = {}): Promise { - const { - timeout = this._timeoutSettings.timeout(), - } = options; + async waitForFileChooser(options: types.TimeoutOptions = {}): Promise { + const { timeout = this._timeoutSettings.timeout() } = options; let callback; const promise = new Promise(x => callback = x); this._fileChooserInterceptors.add(callback); @@ -333,29 +331,28 @@ export class Page extends EventEmitter { return this.mainFrame().waitForNavigation(options); } - async waitForRequest(urlOrPredicate: (string | Function), options: { timeout?: number; } = {}): Promise { - const { - timeout = this._timeoutSettings.timeout(), - } = options; + async waitForEvent(event: string, options: Function | (types.TimeoutOptions & { predicate?: Function }) = {}): Promise { + if (typeof options === 'function') + options = { predicate: 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 { + if (helper.isString(options)) + options = { url: options }; + const { timeout = this._timeoutSettings.timeout() } = options; return helper.waitForEvent(this, Events.Page.Request, (request: network.Request) => { - if (helper.isString(urlOrPredicate)) - return (urlOrPredicate === request.url()); - if (typeof urlOrPredicate === 'function') - return !!(urlOrPredicate(request)); - return false; + return helper.urlMatches(request.url(), options as types.URLMatch); }, timeout, this._disconnectedPromise); } - async waitForResponse(urlOrPredicate: (string | Function), options: { timeout?: number; } = {}): Promise { - const { - timeout = this._timeoutSettings.timeout(), - } = options; + async waitForResponse(options: string | (types.URLMatch & types.TimeoutOptions) = {}): Promise { + if (helper.isString(options)) + options = { url: options }; + const { timeout = this._timeoutSettings.timeout() } = options; return helper.waitForEvent(this, Events.Page.Response, (response: network.Response) => { - if (helper.isString(urlOrPredicate)) - return (urlOrPredicate === response.url()); - if (typeof urlOrPredicate === 'function') - return !!(urlOrPredicate(response)); - return false; + return helper.urlMatches(response.url(), options as types.URLMatch); }, timeout, this._disconnectedPromise); } diff --git a/src/types.ts b/src/types.ts index d925140fd8..84889c82a1 100644 --- a/src/types.ts +++ b/src/types.ts @@ -72,3 +72,20 @@ export type Viewport = { isLandscape?: 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, +}; diff --git a/test/network.spec.js b/test/network.spec.js index 332837e8c3..f672be268b 100644 --- a/test/network.spec.js +++ b/test/network.spec.js @@ -152,7 +152,7 @@ module.exports.addTests = function({testRunner, expect, FFOX, CHROME, WEBKIT}) { page.on('requestfinished', r => requestFinished = requestFinished || r.url().includes('/get')); // send request and wait for server response 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'})), server.waitForRequest('/get'), ]); diff --git a/test/page.spec.js b/test/page.spec.js index d14a3f1c5f..4953eb02f4 100644 --- a/test/page.spec.js +++ b/test/page.spec.js @@ -299,7 +299,7 @@ module.exports.addTests = function({testRunner, expect, headless, playwright, FF it('should work with predicate', async({page, server}) => { await page.goto(server.EMPTY_PAGE); 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(() => { fetch('/digits/1.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}) => { 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); }); it('should respect default timeout', async({page, server}) => { let error = null; 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); }); it('should work with no timeout', async({page, server}) => { await page.goto(server.EMPTY_PAGE); 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(() => { fetch('/digits/1.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'); }); + 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() { @@ -348,19 +426,19 @@ module.exports.addTests = function({testRunner, expect, headless, playwright, FF }); it('should respect timeout', async({page, server}) => { 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); }); it('should respect default timeout', async({page, server}) => { let error = null; 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); }); it('should work with predicate', async({page, server}) => { await page.goto(server.EMPTY_PAGE); 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(() => { fetch('/digits/1.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}) => { await page.goto(server.EMPTY_PAGE); 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(() => { fetch('/digits/1.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'); }); + 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() {