feat(expext): toBeOK for APIResponse (#10596)

This commit is contained in:
Yury Semikhatsky 2021-11-30 18:12:19 -08:00 committed by GitHub
parent 729da65eba
commit d66b7aab3b
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
11 changed files with 128 additions and 28 deletions

View file

@ -268,6 +268,11 @@ export class APIResponse implements api.APIResponse {
_fetchUid(): string {
return this._initializer.fetchUid;
}
async _fetchLog(): Promise<string[]> {
const { log } = await this._request._channel.fetchLog({ fetchUid: this._fetchUid() });
return log;
}
}
type ServerFilePayload = NonNullable<channels.FormField['file']>;

View file

@ -178,8 +178,8 @@ export class APIRequestContextDispatcher extends Dispatcher<APIRequestContext, c
this._object.dispose();
}
async fetch(params: channels.APIRequestContextFetchParams, metadata?: channels.Metadata): Promise<channels.APIRequestContextFetchResult> {
const fetchResponse = await this._object.fetch(params);
async fetch(params: channels.APIRequestContextFetchParams, metadata: CallMetadata): Promise<channels.APIRequestContextFetchResult> {
const fetchResponse = await this._object.fetch(params, metadata);
return {
response: {
url: fetchResponse.url,
@ -196,7 +196,12 @@ export class APIRequestContextDispatcher extends Dispatcher<APIRequestContext, c
return { binary: buffer ? buffer.toString('base64') : undefined };
}
async fetchLog(params: channels.APIRequestContextFetchLogParams, metadata?: channels.Metadata): Promise<channels.APIRequestContextFetchLogResult> {
const log = this._object.fetchLog.get(params.fetchUid) || [];
return { log };
}
async disposeAPIResponse(params: channels.APIRequestContextDisposeAPIResponseParams, metadata?: channels.Metadata): Promise<void> {
this._object.fetchResponses.delete(params.fetchUid);
this._object.disposeResponse(params.fetchUid);
}
}

View file

@ -266,6 +266,7 @@ export interface APIRequestContextChannel extends APIRequestContextEventTarget,
_type_APIRequestContext: boolean;
fetch(params: APIRequestContextFetchParams, metadata?: Metadata): Promise<APIRequestContextFetchResult>;
fetchResponseBody(params: APIRequestContextFetchResponseBodyParams, metadata?: Metadata): Promise<APIRequestContextFetchResponseBodyResult>;
fetchLog(params: APIRequestContextFetchLogParams, metadata?: Metadata): Promise<APIRequestContextFetchLogResult>;
storageState(params?: APIRequestContextStorageStateParams, metadata?: Metadata): Promise<APIRequestContextStorageStateResult>;
disposeAPIResponse(params: APIRequestContextDisposeAPIResponseParams, metadata?: Metadata): Promise<APIRequestContextDisposeAPIResponseResult>;
dispose(params?: APIRequestContextDisposeParams, metadata?: Metadata): Promise<APIRequestContextDisposeResult>;
@ -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 = {

View file

@ -262,6 +262,14 @@ APIRequestContext:
returns:
binary?: binary
fetchLog:
parameters:
fetchUid: string
returns:
log:
type: array
items: string
storageState:
returns:
cookies:

View file

@ -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,

View file

@ -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<string, Buffer> = new Map();
readonly fetchLog: Map<string, string[]> = new Map();
protected static allInstances: Set<APIRequestContext> = 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<Omit<types.APIResponse, 'body'> & { fetchUid: string }> {
async fetch(params: channels.APIRequestContextFetchParams, metadata: CallMetadata): Promise<Omit<types.APIResponse, 'body'> & { 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<types.APIResponse>{
private async _sendRequest(progress: Progress, url: URL, options: https.RequestOptions & { maxRedirects: number, deadline: number }, postData?: Buffer): Promise<types.APIResponse>{
await this._updateRequestCookieHeader(url, options);
return new Promise<types.APIResponse>((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) {

View file

@ -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,

View file

@ -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<string[]>;
}
export function toBeChecked(
this: ReturnType<Expect['getState']>,
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<Expect['getState']>,
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 };
}

View file

@ -103,6 +103,11 @@ declare global {
*/
toBeHidden(options?: { timeout?: number }): Promise<R>;
/**
* Asserts given APIResponse's status is between 200 and 299.
*/
toBeOK(): Promise<R>;
/**
* Asserts given DOM node visible on the screen.
*/

View file

@ -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 }) => {

View file

@ -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`);
});