From d66b7aab3b8e9c6690827c5634e5bea0edabc93a Mon Sep 17 00:00:00 2001 From: Yury Semikhatsky Date: Tue, 30 Nov 2021 18:12:19 -0800 Subject: [PATCH] feat(expext): toBeOK for APIResponse (#10596) --- packages/playwright-core/src/client/fetch.ts | 5 +++ .../src/dispatchers/networkDispatchers.ts | 11 +++-- .../playwright-core/src/protocol/channels.ts | 10 +++++ .../playwright-core/src/protocol/protocol.yml | 8 ++++ .../playwright-core/src/protocol/validator.ts | 3 ++ packages/playwright-core/src/server/fetch.ts | 45 +++++++++++-------- packages/playwright-test/src/expect.ts | 2 + .../playwright-test/src/matchers/matchers.ts | 21 ++++++++- .../playwright-test/types/testExpect.d.ts | 5 +++ tests/browsercontext-fetch.spec.ts | 8 ++-- .../playwright.expect.true.spec.ts | 38 ++++++++++++++++ 11 files changed, 128 insertions(+), 28 deletions(-) diff --git a/packages/playwright-core/src/client/fetch.ts b/packages/playwright-core/src/client/fetch.ts index e100da76d6..61194ea345 100644 --- a/packages/playwright-core/src/client/fetch.ts +++ b/packages/playwright-core/src/client/fetch.ts @@ -268,6 +268,11 @@ export class APIResponse implements api.APIResponse { _fetchUid(): string { return this._initializer.fetchUid; } + + async _fetchLog(): Promise { + const { log } = await this._request._channel.fetchLog({ fetchUid: this._fetchUid() }); + return log; + } } type ServerFilePayload = NonNullable; diff --git a/packages/playwright-core/src/dispatchers/networkDispatchers.ts b/packages/playwright-core/src/dispatchers/networkDispatchers.ts index ee9b4b0dd4..e4b528f057 100644 --- a/packages/playwright-core/src/dispatchers/networkDispatchers.ts +++ b/packages/playwright-core/src/dispatchers/networkDispatchers.ts @@ -178,8 +178,8 @@ export class APIRequestContextDispatcher extends Dispatcher { - const fetchResponse = await this._object.fetch(params); + async fetch(params: channels.APIRequestContextFetchParams, metadata: CallMetadata): Promise { + const fetchResponse = await this._object.fetch(params, metadata); return { response: { url: fetchResponse.url, @@ -196,7 +196,12 @@ export class APIRequestContextDispatcher extends Dispatcher { + const log = this._object.fetchLog.get(params.fetchUid) || []; + return { log }; + } + async disposeAPIResponse(params: channels.APIRequestContextDisposeAPIResponseParams, metadata?: channels.Metadata): Promise { - this._object.fetchResponses.delete(params.fetchUid); + this._object.disposeResponse(params.fetchUid); } } diff --git a/packages/playwright-core/src/protocol/channels.ts b/packages/playwright-core/src/protocol/channels.ts index ddfab13c04..ba30c376aa 100644 --- a/packages/playwright-core/src/protocol/channels.ts +++ b/packages/playwright-core/src/protocol/channels.ts @@ -266,6 +266,7 @@ export interface APIRequestContextChannel extends APIRequestContextEventTarget, _type_APIRequestContext: boolean; fetch(params: APIRequestContextFetchParams, metadata?: Metadata): Promise; fetchResponseBody(params: APIRequestContextFetchResponseBodyParams, metadata?: Metadata): Promise; + fetchLog(params: APIRequestContextFetchLogParams, metadata?: Metadata): Promise; storageState(params?: APIRequestContextStorageStateParams, metadata?: Metadata): Promise; disposeAPIResponse(params: APIRequestContextDisposeAPIResponseParams, metadata?: Metadata): Promise; dispose(params?: APIRequestContextDisposeParams, metadata?: Metadata): Promise; @@ -307,6 +308,15 @@ export type APIRequestContextFetchResponseBodyOptions = { export type APIRequestContextFetchResponseBodyResult = { binary?: Binary, }; +export type APIRequestContextFetchLogParams = { + fetchUid: string, +}; +export type APIRequestContextFetchLogOptions = { + +}; +export type APIRequestContextFetchLogResult = { + log: string[], +}; export type APIRequestContextStorageStateParams = {}; export type APIRequestContextStorageStateOptions = {}; export type APIRequestContextStorageStateResult = { diff --git a/packages/playwright-core/src/protocol/protocol.yml b/packages/playwright-core/src/protocol/protocol.yml index a50eacfecb..84dd768fda 100644 --- a/packages/playwright-core/src/protocol/protocol.yml +++ b/packages/playwright-core/src/protocol/protocol.yml @@ -262,6 +262,14 @@ APIRequestContext: returns: binary?: binary + fetchLog: + parameters: + fetchUid: string + returns: + log: + type: array + items: string + storageState: returns: cookies: diff --git a/packages/playwright-core/src/protocol/validator.ts b/packages/playwright-core/src/protocol/validator.ts index 1d440119df..12136eab6b 100644 --- a/packages/playwright-core/src/protocol/validator.ts +++ b/packages/playwright-core/src/protocol/validator.ts @@ -173,6 +173,9 @@ export function createScheme(tChannel: (name: string) => Validator): Scheme { scheme.APIRequestContextFetchResponseBodyParams = tObject({ fetchUid: tString, }); + scheme.APIRequestContextFetchLogParams = tObject({ + fetchUid: tString, + }); scheme.APIRequestContextStorageStateParams = tOptional(tObject({})); scheme.APIRequestContextDisposeAPIResponseParams = tObject({ fetchUid: tString, diff --git a/packages/playwright-core/src/server/fetch.ts b/packages/playwright-core/src/server/fetch.ts index eb8cd3694c..29d3ec403f 100644 --- a/packages/playwright-core/src/server/fetch.ts +++ b/packages/playwright-core/src/server/fetch.ts @@ -17,19 +17,19 @@ import * as http from 'http'; import * as https from 'https'; import { HttpsProxyAgent } from 'https-proxy-agent'; +import { Progress, ProgressController } from './progress'; import { SocksProxyAgent } from 'socks-proxy-agent'; import { pipeline, Readable, Transform } from 'stream'; import url from 'url'; import zlib from 'zlib'; import { HTTPCredentials } from '../../types/types'; import * as channels from '../protocol/channels'; -import { debugLogger } from '../utils/debugLogger'; import { TimeoutSettings } from '../utils/timeoutSettings'; import { assert, createGuid, getPlaywrightVersion, monotonicTime } from '../utils/utils'; import { BrowserContext } from './browserContext'; import { CookieStore, domainMatches } from './cookieStore'; import { MultipartFormData } from './formData'; -import { SdkObject } from './instrumentation'; +import { CallMetadata, SdkObject } from './instrumentation'; import { Playwright } from './playwright'; import * as types from './types'; import { HeadersArray, ProxySettings } from './types'; @@ -50,6 +50,7 @@ export abstract class APIRequestContext extends SdkObject { }; readonly fetchResponses: Map = new Map(); + readonly fetchLog: Map = new Map(); protected static allInstances: Set = new Set(); static findResponseBody(guid: string): Buffer | undefined { @@ -69,9 +70,15 @@ export abstract class APIRequestContext extends SdkObject { protected _disposeImpl() { APIRequestContext.allInstances.delete(this); this.fetchResponses.clear(); + this.fetchLog.clear(); this.emit(APIRequestContext.Events.Dispose); } + disposeResponse(fetchUid: string) { + this.fetchResponses.delete(fetchUid); + this.fetchLog.delete(fetchUid); + } + abstract dispose(): void; abstract _defaultOptions(): FetchRequestOptions; @@ -85,7 +92,7 @@ export abstract class APIRequestContext extends SdkObject { return uid; } - async fetch(params: channels.APIRequestContextFetchParams): Promise & { fetchUid: string }> { + async fetch(params: channels.APIRequestContextFetchParams, metadata: CallMetadata): Promise & { fetchUid: string }> { const headers: { [name: string]: string } = {}; const defaults = this._defaultOptions(); headers['user-agent'] = defaults.userAgent; @@ -141,15 +148,19 @@ export abstract class APIRequestContext extends SdkObject { requestUrl.searchParams.set(name, value); } - let postData; + let postData: Buffer | undefined; if (['POST', 'PUT', 'PATCH', 'DELETE'].includes(method)) postData = serializePostData(params, headers); else if (params.postData || params.jsonData || params.formData || params.multipartData) throw new Error(`Method ${method} does not accept post data`); if (postData) headers['content-length'] = String(postData.byteLength); - const fetchResponse = await this._sendRequest(requestUrl, options, postData); + const controller = new ProgressController(metadata, this); + const fetchResponse = await controller.run(progress => { + return this._sendRequest(progress, requestUrl, options, postData); + }); const fetchUid = this._storeResponseBody(fetchResponse.body); + this.fetchLog.set(fetchUid, controller.metadata.log); if (params.failOnStatusCode && (fetchResponse.status < 200 || fetchResponse.status >= 400)) throw new Error(`${fetchResponse.status} ${fetchResponse.statusText}`); return { ...fetchResponse, fetchUid }; @@ -191,17 +202,15 @@ export abstract class APIRequestContext extends SdkObject { } } - private async _sendRequest(url: URL, options: https.RequestOptions & { maxRedirects: number, deadline: number }, postData?: Buffer): Promise{ + private async _sendRequest(progress: Progress, url: URL, options: https.RequestOptions & { maxRedirects: number, deadline: number }, postData?: Buffer): Promise{ await this._updateRequestCookieHeader(url, options); return new Promise((fulfill, reject) => { const requestConstructor: ((url: URL, options: http.RequestOptions, callback?: (res: http.IncomingMessage) => void) => http.ClientRequest) = (url.protocol === 'https:' ? https : http).request; const request = requestConstructor(url, options, async response => { - if (debugLogger.isEnabled('api')) { - debugLogger.log('api', `← ${response.statusCode} ${response.statusMessage}`); - for (const [name, value] of Object.entries(response.headers)) - debugLogger.log('api', ` ${name}: ${value}`); - } + progress.log(`← ${response.statusCode} ${response.statusMessage}`); + for (const [name, value] of Object.entries(response.headers)) + progress.log(` ${name}: ${value}`); if (response.headers['set-cookie']) await this._updateCookiesFromHeader(response.url || url.toString(), response.headers['set-cookie']); if (redirectStatus.includes(response.statusCode!)) { @@ -242,7 +251,7 @@ export abstract class APIRequestContext extends SdkObject { // HTTP-redirect fetch step 4: If locationURL is null, then return response. if (response.headers.location) { const locationURL = new URL(response.headers.location, url); - fulfill(this._sendRequest(locationURL, redirectOptions, postData)); + fulfill(this._sendRequest(progress, locationURL, redirectOptions, postData)); request.destroy(); return; } @@ -254,7 +263,7 @@ export abstract class APIRequestContext extends SdkObject { const { username, password } = credentials; const encoded = Buffer.from(`${username || ''}:${password || ''}`).toString('base64'); options.headers!['authorization'] = `Basic ${encoded}`; - fulfill(this._sendRequest(url, options, postData)); + fulfill(this._sendRequest(progress, url, options, postData)); request.destroy(); return; } @@ -304,12 +313,10 @@ export abstract class APIRequestContext extends SdkObject { this.on(APIRequestContext.Events.Dispose, disposeListener); request.on('close', () => this.off(APIRequestContext.Events.Dispose, disposeListener)); - if (debugLogger.isEnabled('api')) { - debugLogger.log('api', `→ ${options.method} ${url.toString()}`); - if (options.headers) { - for (const [name, value] of Object.entries(options.headers)) - debugLogger.log('api', ` ${name}: ${value}`); - } + progress.log(`→ ${options.method} ${url.toString()}`); + if (options.headers) { + for (const [name, value] of Object.entries(options.headers)) + progress.log(` ${name}: ${value}`); } if (options.deadline) { diff --git a/packages/playwright-test/src/expect.ts b/packages/playwright-test/src/expect.ts index 69ac339225..6a5638ad16 100644 --- a/packages/playwright-test/src/expect.ts +++ b/packages/playwright-test/src/expect.ts @@ -29,6 +29,7 @@ import { toBeEnabled, toBeFocused, toBeHidden, + toBeOK, toBeVisible, toContainText, toHaveAttribute, @@ -101,6 +102,7 @@ const customMatchers = { toBeEnabled, toBeFocused, toBeHidden, + toBeOK, toBeVisible, toContainText, toHaveAttribute, diff --git a/packages/playwright-test/src/matchers/matchers.ts b/packages/playwright-test/src/matchers/matchers.ts index 1b35c17ac2..03ebcba26a 100644 --- a/packages/playwright-test/src/matchers/matchers.ts +++ b/packages/playwright-test/src/matchers/matchers.ts @@ -14,18 +14,23 @@ * limitations under the License. */ -import { Locator, Page } from 'playwright-core'; +import { Locator, Page, APIResponse } from 'playwright-core'; import { FrameExpectOptions } from 'playwright-core/lib/client/types'; import { constructURLBasedOnBaseURL } from 'playwright-core/lib/utils/utils'; import type { Expect } from '../types'; +import { expectType } from '../util'; import { toBeTruthy } from './toBeTruthy'; import { toEqual } from './toEqual'; -import { toExpectedTextValues, toMatchText } from './toMatchText'; +import { callLogText, toExpectedTextValues, toMatchText } from './toMatchText'; interface LocatorEx extends Locator { _expect(expression: string, options: FrameExpectOptions): Promise<{ matches: boolean, received?: any, log?: string[] }>; } +interface APIResponseEx extends APIResponse { + _fetchLog(): Promise; +} + export function toBeChecked( this: ReturnType, locator: LocatorEx, @@ -263,3 +268,15 @@ export function toHaveURL( return await locator._expect('to.have.url', { expectedText, isNot, timeout }); }, expected, options); } + +export async function toBeOK( + this: ReturnType, + response: APIResponseEx +) { + const matcherName = 'toBeOK'; + expectType(response, 'APIResponse', matcherName); + const log = (this.isNot === response.ok()) ? await response._fetchLog() : []; + const message = () => this.utils.matcherHint(matcherName, undefined, '', { isNot: this.isNot }) + callLogText(log); + const pass = response.ok(); + return { message, pass }; +} diff --git a/packages/playwright-test/types/testExpect.d.ts b/packages/playwright-test/types/testExpect.d.ts index 2a9c910ba1..f8457fb63a 100644 --- a/packages/playwright-test/types/testExpect.d.ts +++ b/packages/playwright-test/types/testExpect.d.ts @@ -103,6 +103,11 @@ declare global { */ toBeHidden(options?: { timeout?: number }): Promise; + /** + * Asserts given APIResponse's status is between 200 and 299. + */ + toBeOK(): Promise; + /** * Asserts given DOM node visible on the screen. */ diff --git a/tests/browsercontext-fetch.spec.ts b/tests/browsercontext-fetch.spec.ts index f905982126..62b0542c7b 100644 --- a/tests/browsercontext-fetch.spec.ts +++ b/tests/browsercontext-fetch.spec.ts @@ -69,7 +69,7 @@ it('should throw on network error', async ({ context, server }) => { req.socket.destroy(); }); const error = await context.request.get(server.PREFIX + '/test').catch(e => e); - expect(error.message).toBe('apiRequestContext.get: socket hang up'); + expect(error.message).toContain('apiRequestContext.get: socket hang up'); }); it('should throw on network error after redirect', async ({ context, server }) => { @@ -78,7 +78,7 @@ it('should throw on network error after redirect', async ({ context, server }) = req.socket.destroy(); }); const error = await context.request.get(server.PREFIX + '/redirect').catch(e => e); - expect(error.message).toBe('apiRequestContext.get: socket hang up'); + expect(error.message).toContain('apiRequestContext.get: socket hang up'); }); it('should throw on network error when sending body', async ({ context, server }) => { @@ -92,7 +92,7 @@ it('should throw on network error when sending body', async ({ context, server } req.socket.destroy(); }); const error = await context.request.get(server.PREFIX + '/test').catch(e => e); - expect(error.message).toBe('apiRequestContext.get: aborted'); + expect(error.message).toContain('apiRequestContext.get: aborted'); }); it('should throw on network error when sending body after redirect', async ({ context, server }) => { @@ -107,7 +107,7 @@ it('should throw on network error when sending body after redirect', async ({ co req.socket.destroy(); }); const error = await context.request.get(server.PREFIX + '/redirect').catch(e => e); - expect(error.message).toBe('apiRequestContext.get: aborted'); + expect(error.message).toContain('apiRequestContext.get: aborted'); }); it('should add session cookies to request', async ({ context, server }) => { diff --git a/tests/playwright-test/playwright.expect.true.spec.ts b/tests/playwright-test/playwright.expect.true.spec.ts index 50a8889c9a..3d3da42b5f 100644 --- a/tests/playwright-test/playwright.expect.true.spec.ts +++ b/tests/playwright-test/playwright.expect.true.spec.ts @@ -322,3 +322,41 @@ test('should print syntax error', async ({ runInlineTest }) => { expect(result.exitCode).toBe(1); expect(result.output).toContain(`Unexpected token "]" while parsing selector "row]"`); }); + +test('should support toBeOK', async ({ runInlineTest, server }) => { + const result = await runInlineTest({ + 'a.test.ts': ` + const { test } = pwt; + + test('pass with response', async ({ page }) => { + const res = await page.request.get('${server.EMPTY_PAGE}'); + await expect(res).toBeOK(); + }); + + test('pass with not', async ({ page }) => { + const res = await page.request.get('${server.PREFIX}/unknown'); + await expect(res).not.toBeOK(); + }); + + test('fail with invalid argument', async ({ page }) => { + await expect(page).toBeOK(); + }); + + test('fail with promise', async ({ page }) => { + const res = page.request.get('${server.EMPTY_PAGE}').catch(e => {}); + await expect(res).toBeOK(); + }); + + test('fail', async ({ page }) => { + const res = await page.request.get('${server.PREFIX}/unknown'); + await expect(res).toBeOK(); + }); + `, + }, { workers: 1 }); + expect(result.passed).toBe(2); + expect(result.failed).toBe(3); + expect(result.exitCode).toBe(1); + expect(result.output).toContain(`→ GET ${server.PREFIX}/unknown`); + expect(result.output).toContain(`← 404 Not Found`); + expect(result.output).toContain(`Error: toBeOK can be only used with APIResponse object`); +});