diff --git a/.github/workflows/coveralls.yml b/.github/workflows/coveralls.yml index 1baa5a12..88455758 100644 --- a/.github/workflows/coveralls.yml +++ b/.github/workflows/coveralls.yml @@ -23,7 +23,7 @@ jobs: - name: yarn install, yarn test run: | yarn install - yarn test + yarn test --silent - name: Coveralls uses: coverallsapp/github-action@v2 diff --git a/package.json b/package.json index 3c090951..fc3b7ef2 100644 --- a/package.json +++ b/package.json @@ -28,12 +28,12 @@ "build": "rollup -c", "postbuild": "cp -R ./types ./dist", "build:watch": "rollup -c -w", - "test": "jest --silent", + "test": "jest", "lint": "eslint --ext .js,.ts ./src", "lint:fix": "eslint --ext .js,.ts --fix ./src", "prettier": "prettier --write '**/*.{js,jsx,tsx,ts,md,json}'", "commit": "git-cz", - "prepublishOnly": "yarn test && yarn build", + "prepublishOnly": "yarn test --silent && yarn build", "release:major": "standard-version --release-as major", "release:minor": "standard-version --release-as minor", "release:patch": "standard-version --release-as patch" diff --git a/src/api/index.ts b/src/api/index.ts index 54cb8912..3b413ce5 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -1,3 +1,4 @@ +import { psLog } from 'src/utils'; import { combineName, parseUserAgent } from 'src/utils/ua'; interface TResponse { @@ -14,7 +15,7 @@ interface TCreateRoom { group: string; tags: Record; } -/* c8 ignore start */ + const resolvedProtocol = (() => { try { const { protocol } = new URL(document.currentScript?.getAttribute('src')!); @@ -23,14 +24,13 @@ const resolvedProtocol = (() => { } } catch (e) { console.error(e); - console.error( - '[PageSpy] Failed to resolve the protocol and fallback to [http://, ws://]', + psLog.error( + 'Failed to resolve the protocol and fallback to [http://, ws://]', ); } return ['http://', 'ws://']; })(); -/* c8 ignore stop */ export default class Request { constructor(public base: string = '') { /* c8 ignore next 3 */ diff --git a/src/index.ts b/src/index.ts index a171e676..c35a6bb7 100644 --- a/src/index.ts +++ b/src/index.ts @@ -12,7 +12,7 @@ import { StoragePlugin } from './plugins/storage'; import socketStore from './utils/socket'; import Request from './api'; -import { getRandomId } from './utils'; +import { getRandomId, psLog } from './utils'; import pkg from '../package.json'; import type { UElement } from './utils/moveable'; @@ -229,7 +229,7 @@ export default class PageSpy { moveable(logo); this.handleDeviceDPR(); - console.log('[PageSpy] init success.'); + psLog.log('Init success.'); } handleDeviceDPR() { diff --git a/src/plugins/console.ts b/src/plugins/console.ts index 18583822..5e7c6b2c 100644 --- a/src/plugins/console.ts +++ b/src/plugins/console.ts @@ -5,11 +5,11 @@ import atom from 'src/utils/atom'; import type PageSpyPlugin from './index'; export default class ConsolePlugin implements PageSpyPlugin { - name: string = 'ConsolePlugin'; + public name: string = 'ConsolePlugin'; - console: Record = {}; + private console: Record = {}; - onCreated() { + public onCreated() { const type: SpyConsole.ProxyType[] = ['log', 'info', 'error', 'warn']; type.forEach((item) => { this.console[item] = window.console[item]; @@ -23,7 +23,7 @@ export default class ConsolePlugin implements PageSpyPlugin { }); } - printLog(data: SpyConsole.DataItem) { + private printLog(data: SpyConsole.DataItem) { if (data.logs && data.logs.length) { this.console[data.logType](...data.logs); // eslint-disable-next-line no-param-reassign diff --git a/src/plugins/error.ts b/src/plugins/error.ts index d7abf6cd..f5bcb82a 100644 --- a/src/plugins/error.ts +++ b/src/plugins/error.ts @@ -4,11 +4,11 @@ import socketStore from 'src/utils/socket'; import type PageSpyPlugin from './index'; export default class ErrorPlugin implements PageSpyPlugin { - name = 'ErrorPlugin'; + public name = 'ErrorPlugin'; - error: OnErrorEventHandler = null; + private error: OnErrorEventHandler = null; - onCreated() { + public onCreated() { const that = this; this.error = window.onerror; @@ -35,7 +35,7 @@ export default class ErrorPlugin implements PageSpyPlugin { true, ); - // Promise unhandleRejection Error + // Promise unhandledRejection Error window.addEventListener( 'unhandledrejection', (evt: PromiseRejectionEvent) => { @@ -44,7 +44,7 @@ export default class ErrorPlugin implements PageSpyPlugin { ); } - static sendMessage(data: any) { + public static sendMessage(data: any) { // Treat `error` data as `console` const message = makeMessage(DEBUG_MESSAGE_TYPE.CONSOLE, { logType: 'error', diff --git a/src/plugins/index.ts b/src/plugins/index.ts index c807805e..36f35411 100644 --- a/src/plugins/index.ts +++ b/src/plugins/index.ts @@ -1,7 +1,7 @@ export default abstract class PageSpyPlugin { - constructor(public name: string) {} + public constructor(public name: string) {} // 加载后立即生效 - abstract onCreated?(): void; + public abstract onCreated?(): void; // 用户主动触发的回调 - abstract onLoaded?(): void; + public abstract onLoaded?(): void; } diff --git a/src/plugins/network/index.ts b/src/plugins/network/index.ts index a9f9dea4..14e6d9c3 100644 --- a/src/plugins/network/index.ts +++ b/src/plugins/network/index.ts @@ -1,539 +1,21 @@ // eslint-disable no-case-declarations -import { makeMessage, DEBUG_MESSAGE_TYPE } from 'src/utils/message'; -import socketStore from 'src/utils/socket'; -import { blob2base64 } from 'src/utils/blob'; -import { - getContentType, - getPrototypeName, - getRandomId, - isArrayBuffer, - isBlob, - isPlainObject, - isString, - toStringTag, -} from 'src/utils'; import type PageSpyPlugin from '../index'; -import RequestItem from './request-item'; - -declare global { - interface XMLHttpRequest { - pageSpyRequestId: string; - pageSpyRequestMethod: string; - pageSpyRequestUrl: string; - } -} - -/* c8 ignore start */ -function getURL(url: string) { - if (url.startsWith('//')) { - // eslint-disable-next-line no-param-reassign - url = window.location.protocol + url; - } - if (url.startsWith('http')) { - return new URL(url); - } - return new URL(url, window.location.href); -} -/* c8 ignore stop */ - -// File size is not recommended to exceed 6M, -// 10M files would result negative performance impact distinctly in local-test. -const MAX_SIZE = 1024 * 1024 * 6; -const Reason = { - EXCEED_SIZE: 'Exceed maximum limit', -}; +import XhrProxy from './proxy/xhr-proxy'; +import FetchProxy from './proxy/fetch-proxy'; +import BeaconProxy from './proxy/beacon-proxy'; export default class NetworkPlugin implements PageSpyPlugin { - name = 'NetworkPlugin'; - - xhrOpen: XMLHttpRequest['open'] | null = null; - - xhrSend: XMLHttpRequest['send'] | null = null; - - xhrSetRequestHeader: XMLHttpRequest['setRequestHeader'] | null = null; - - fetch: WindowOrWorkerGlobalScope['fetch'] | null = null; - - sendBeacon: Navigator['sendBeacon'] | null = null; - - reqList: Record = {}; - - onCreated() { - this.xhrProxy(); - this.fetchProxy(); - this.sendBeaconProxy(); - } - - xhrProxy() { - const that = this; - /* c8 ignore start */ - if (!window.XMLHttpRequest) { - return; - } - /* c8 ignore stop */ - const { open, send, setRequestHeader } = window.XMLHttpRequest.prototype; - this.xhrOpen = open; - this.xhrSend = send; - this.xhrSetRequestHeader = setRequestHeader; - - window.XMLHttpRequest.prototype.open = function (...args: any[]) { - const XMLReq = this; - const method = args[0]; - const url = args[1]; - const id = getRandomId(); - let timer: number | null = null; - - this.pageSpyRequestId = id; - this.pageSpyRequestMethod = method; - this.pageSpyRequestUrl = url; - - const onreadystatechange = this.onreadystatechange || function () {}; - const onReadyStateChange = function (...evts: any) { - if (!that.reqList[id]) { - that.reqList[id] = new RequestItem(id); - } - const req = that.reqList[id]; - req.readyState = XMLReq.readyState; - - const header = XMLReq.getAllResponseHeaders() || ''; - const headerArr = header.trim().split(/[\r\n]+/); - - switch (XMLReq.readyState) { - /* c8 ignore next */ - case 0: - case 1: - req.status = XMLReq.status; - req.statusText = 'Pending'; - if (!req.startTime) { - req.startTime = Date.now(); - } - break; - // Header received - case 2: - req.status = XMLReq.status; - req.statusText = 'Loading'; - req.responseHeader = {}; - headerArr.forEach((item) => { - const parts = item.split(': '); - const headerKey = parts.shift(); - const value = parts.join(': '); - req.responseHeader![headerKey!] = value; - }); - break; - // Loading and download - case 3: - req.status = XMLReq.status; - req.statusText = 'Loading'; - break; - // Done - case 4: - clearInterval(timer as number); - req.status = XMLReq.status; - req.statusText = 'Done'; - req.endTime = Date.now(); - /* c8 ignore next */ - req.costTime = req.endTime - (req.startTime || req.endTime); - req.response = XMLReq.response; - break; - /* c8 ignore start */ - default: - clearInterval(timer as number); - req.status = XMLReq.status; - req.statusText = 'Unknown'; - break; - /* c8 ignore stop */ - } - - // update response by responseType - switch (XMLReq.responseType) { - case '': - case 'text': - if (isString(XMLReq.response)) { - try { - req.response = JSON.parse(XMLReq.response); - } catch (e) { - // not a JSON string - req.response = XMLReq.response; - } - } /* c8 ignore start */ else if ( - typeof XMLReq.response !== 'undefined' - ) { - req.response = toStringTag(XMLReq.response); - } - /* c8 ignore stop */ - break; - case 'json': - if (typeof XMLReq.response !== 'undefined') { - req.response = JSON.stringify(XMLReq.response, null, 2); - } - break; - case 'blob': - case 'arraybuffer': - if (XMLReq.response) { - let blob = XMLReq.response; - if (isArrayBuffer(blob)) { - const contentType = req.responseHeader!['content-type']; - blob = new Blob([blob], { type: contentType }); - } - if (isBlob(blob)) { - if (blob.size <= MAX_SIZE) { - blob2base64(blob, (data) => { - if (isString(data)) { - req.response = data; - that.collectRequest(XMLReq.pageSpyRequestId, req); - } - }); - } /* c8 ignore start */ else { - req.response = `[object ${XMLReq.responseType}]`; - req.responseReason = Reason.EXCEED_SIZE; - } - /* c8 ignore stop */ - } - } - break; - /* c8 ignore start */ - case 'document': - default: - if (typeof XMLReq.response !== 'undefined') { - req.response = Object.prototype.toString.call(XMLReq.response); - } - break; - /* c8 ignore stop */ - } - that.collectRequest(XMLReq.pageSpyRequestId, req); - return onreadystatechange.apply(XMLReq, evts); - }; - - XMLReq.onreadystatechange = onReadyStateChange; - - // some 3rd-libraries will change XHR's default function - // so we use a timer to avoid lost tracking of readyState - let preState = -1; - timer = window.setInterval(() => { - // eslint-disable-next-line eqeqeq - if (preState != XMLReq.readyState) { - preState = XMLReq.readyState; - onReadyStateChange.call(XMLReq); - } - }, 10); + public name = 'NetworkPlugin'; - return open.apply(XMLReq, args as any); - }; - - window.XMLHttpRequest.prototype.setRequestHeader = function (key, value) { - const req = that.reqList[this.pageSpyRequestId]; - if (req) { - if (!req.requestHeader) { - req.requestHeader = {}; - } - (req.requestHeader as Record)[key] = value; - } - return setRequestHeader.apply(this, [key, value]); - }; - - window.XMLHttpRequest.prototype.send = function (body) { - const XMLReq = this; - const { - pageSpyRequestId = getRandomId(), - pageSpyRequestMethod = 'GET', - pageSpyRequestUrl = '', - } = XMLReq; - /* c8 ignore start */ - const req = - that.reqList[pageSpyRequestId] || new RequestItem(pageSpyRequestId); - const query = pageSpyRequestUrl.split('?') || []; - req.url = pageSpyRequestUrl; - req.name = query.shift() || ''; - req.name = req.name.replace(/[/]*$/, '').split('/').pop() || ''; - /* c8 ignore stop */ - req.method = pageSpyRequestMethod.toUpperCase(); - req.requestType = 'xhr'; - req.responseType = XMLReq.responseType; - req.withCredentials = XMLReq.withCredentials; - - if (query.length > 0) { - req.name += `?${query}`; - req.getData = {}; - const queryArr = query.join('?').split('&'); - queryArr.forEach((item) => { - const [key, value] = item.split('='); - req.getData![key] = decodeURIComponent(value); - }); - } - if (body && req.method === 'POST') { - /* c8 ignore start */ - if (isString(body)) { - try { - req.postData = JSON.parse(body as string); - } catch (e) { - req.postData = body as string; - } - } else if (isPlainObject(body)) { - req.postData = body as unknown as Record; - } else { - req.postData = '[object Object]'; - } - /* c8 ignore stop */ - } - return send.apply(XMLReq, [body]); - }; - } - - fetchProxy() { - const that = this; - const originFetch = window.fetch; - - /* c8 ignore next 3 */ - if (!originFetch) { - return; - } - this.fetch = originFetch; - window.fetch = function (input: RequestInfo | URL, init: RequestInit = {}) { - const id = getRandomId(); - that.reqList[id] = new RequestItem(id); - const req = that.reqList[id]; - let method = 'GET'; - let url: URL; - let requestHeader: HeadersInit | null; - let fetchResponse: Response | null; - - if (isString(input)) { - // when `input` is a string - /* c8 ignore next */ - method = init.method || 'GET'; - url = getURL(input); - requestHeader = init?.headers || null; - } else { - // when `input` is a `Request` object - /* c8 ignore next */ - method = (input).method || 'GET'; - url = getURL((input).url); - requestHeader = (input).headers; - } - - /* c8 ignore start */ - const query = url.href.split('?') || []; - req.name = query.shift() || ''; - req.name = req.name.replace(/[/]*$/, '').split('/').pop() || ''; - /* c8 ignore stop */ - req.method = method.toUpperCase(); - req.url = url.toString(); - req.requestType = 'fetch'; - req.status = 0; - req.statusText = 'Pending'; - req.startTime = Date.now(); - - if (init.credentials && init.credentials !== 'omit') { - req.withCredentials = true; - } - - if (requestHeader instanceof Headers) { - req.requestHeader = {}; - requestHeader.forEach((value, key) => { - (req.requestHeader as Record)[key] = value; - }); - } else { - req.requestHeader = requestHeader; - } - - if (url.search) { - req.name += url.search; - req.getData = {}; - url.searchParams.forEach((value, key) => { - req.getData![key] = value; - }); - } - - /* c8 ignore start */ - if (req.method === 'POST') { - if (isString(input)) { - // when `input` is a string - req.postData = NetworkPlugin.getFormattedBody(init?.body); - } else { - // when `input` is a `Request` object - // cannot get real type of request's body, so just display "[object Object]" - req.postData = '[object Object]'; - } - } - /* c8 ignore stop */ - - const request = isString(input) ? url.toString() : input; - - that.collectRequest(id, req); - return originFetch(request, init) - .then((res) => { - fetchResponse = res; - req.endTime = Date.now(); - /* c8 ignore next 3 */ - req.costTime = req.endTime - (req.startTime || req.endTime); - req.status = res.status || 200; - req.statusText = res.statusText || 'Done'; - req.responseHeader = {}; - res.headers.forEach((value, key) => { - req.responseHeader![key] = value; - }); - - const contentType = res.headers.get('content-type'); - if (contentType) { - if (contentType.includes('application/json')) { - req.responseType = 'json'; - return res.clone().text(); - } - /* c8 ignore start */ - if ( - contentType.includes('text/html') || - contentType.includes('text/plain') - ) { - req.responseType = 'text'; - return res.clone().text(); - } - } - req.responseType = 'blob'; - return res.clone().blob(); - /* c8 ignore stop */ - }) - .then((res) => { - /* c8 ignore start */ - switch (req.responseType) { - case 'text': - case 'json': - try { - req.response = JSON.parse(res as string); - } catch (e) { - req.response = res; - req.responseType = 'text'; - } - break; - case 'blob': - // eslint-disable-next-line no-case-declarations - const blob = res as Blob; - if (blob.size <= MAX_SIZE) { - blob2base64(blob, (data) => { - if (isString(data)) { - req.response = data; - that.collectRequest(id, req); - } - }); - } else { - req.response = '[object Blob]'; - req.responseReason = Reason.EXCEED_SIZE; - } - break; - default: - req.response = res; - break; - } - /* c8 ignore stop */ - - return fetchResponse!; - }) - .finally(() => { - fetchResponse = null; - req.readyState = 4; - that.collectRequest(id, req); - }); - }; - } - - sendBeaconProxy() { - const originSendBeacon = window.navigator.sendBeacon; - /* c8 ignore next 3 */ - if (!originSendBeacon) { - return; - } - - const that = this; - this.sendBeacon = originSendBeacon; - window.navigator.sendBeacon = function ( - url: string, - data?: BodyInit | null, - ) { - const id = getRandomId(); - const req = new RequestItem(id); - that.reqList[id] = req; - - const urlObj = getURL(url); - /* c8 ignore next */ - req.name = urlObj.href.split('/').pop() || ''; - req.method = 'POST'; - req.url = url.toString(); - req.status = 0; - req.statusText = 'Pending'; - req.requestType = 'ping'; - req.requestHeader = { 'Content-Type': getContentType(data) }; - req.startTime = Date.now(); - req.postData = NetworkPlugin.getFormattedBody(data); - req.response = ''; - - if (urlObj.search) { - req.getData = {}; - urlObj.searchParams.forEach((value, key) => { - (req.getData as Record)[key] = value; - }); - } - - const result = originSendBeacon.call(window.navigator, url, data); - if (result) { - req.status = 200; - req.statusText = 'Sent'; - req.endTime = Date.now(); - /* c8 ignore next */ - req.costTime = req.endTime - (req.startTime || req.endTime); - req.readyState = 4; - } /* c8 ignore start */ else { - req.status = 500; - req.statusText = 'Unknown'; - } - /* c8 ignore stop */ - that.collectRequest(id, req); - return result; - }; - } - - collectRequest(id: string, req: RequestItem) { - if (!this.reqList[id]) { - this.reqList[id] = req; - } - const message = makeMessage( - DEBUG_MESSAGE_TYPE.NETWORK, - { - ...req, - }, - false, - ); - socketStore.broadcastMessage(message); - } + public xhrProxy: XhrProxy | null = null; - static getFormattedBody(body?: BodyInit | null) { - /* c8 ignore start */ - if (!body) { - return null; - } - let ret: Record | string = ''; - const type = getPrototypeName(body); - switch (type) { - case 'String': - try { - // try to parse as JSON - ret = JSON.parse(body); - } catch (e) { - // not a json, return original string - ret = body; - } - break; + public fetchProxy: FetchProxy | null = null; - case 'URLSearchParams': - ret = {}; - (body as URLSearchParams).forEach((value, key) => { - (ret as Record)[key] = value; - }); - break; + public beaconProxy: BeaconProxy | null = null; - default: - ret = `[object ${type}]`; - break; - } - /* c8 ignore stop */ - return ret; + public onCreated() { + this.xhrProxy = new XhrProxy(); + this.fetchProxy = new FetchProxy(); + this.beaconProxy = new BeaconProxy(); } } diff --git a/src/plugins/network/proxy/base.ts b/src/plugins/network/proxy/base.ts new file mode 100644 index 00000000..b5958668 --- /dev/null +++ b/src/plugins/network/proxy/base.ts @@ -0,0 +1,64 @@ +import { makeMessage, DEBUG_MESSAGE_TYPE } from 'src/utils/message'; +import socketStore from 'src/utils/socket'; +import { psLog } from 'src/utils'; +import RequestItem from './request-item'; + +type RequestStore = Record; +export default class NetworkProxyBase { + private reqMap: RequestStore = Object.create(null); + + public getRequestMap() { + return this.reqMap; + } + + protected getRequest(id: string) { + const req = this.reqMap[id]; + return req; + } + + protected createRequest(id: string) { + if (!id) { + psLog.error('The "id" is required when init request object'); + return false; + } + if (this.reqMap[id]) { + psLog.warn( + 'The request object has been in store, disallow duplicate create', + ); + return false; + } + this.reqMap[id] = new RequestItem(id); + return true; + } + + protected setRequest(id: string, req: RequestItem) { + if (!id || !req) return false; + this.reqMap[id] = req; + return true; + } + + protected sendRequestItem(id: string, req: RequestItem) { + if (!this.reqMap[id]) { + this.reqMap[id] = req; + } + + const message = makeMessage( + DEBUG_MESSAGE_TYPE.NETWORK, + { + ...req, + }, + false, + ); + socketStore.broadcastMessage(message); + this.deferDeleteRequest(id); + } + + private deferDeleteRequest(id: string) { + const req = this.getRequest(id); + if (req && req.readyState === 4) { + setTimeout(() => { + delete this.reqMap[id]; + }, 3000); + } + } +} diff --git a/src/plugins/network/proxy/beacon-proxy.ts b/src/plugins/network/proxy/beacon-proxy.ts new file mode 100644 index 00000000..b4896831 --- /dev/null +++ b/src/plugins/network/proxy/beacon-proxy.ts @@ -0,0 +1,68 @@ +import { getRandomId, psLog } from 'src/utils'; +import NetworkProxyBase from './base'; +import { + addContentTypeHeader, + getFormattedBody, + resolveUrlInfo, +} from './common'; + +export default class BeaconProxy extends NetworkProxyBase { + private sendBeacon: Navigator['sendBeacon'] | null = null; + + public constructor() { + super(); + this.initProxyHandler(); + } + + private initProxyHandler() { + const originSendBeacon = window.navigator.sendBeacon; + if (!originSendBeacon) { + return; + } + + const that = this; + this.sendBeacon = originSendBeacon; + window.navigator.sendBeacon = function ( + url: string, + data?: BodyInit | null, + ) { + const result = originSendBeacon.call(window.navigator, url, data); + + const id = getRandomId(); + that.createRequest(id); + const req = that.getRequest(id); + if (req) { + const urlInfo = resolveUrlInfo(url); + req.url = urlInfo.url; + req.name = urlInfo.name; + req.getData = urlInfo.query; + req.method = 'POST'; + req.status = 0; + req.statusText = 'Pending'; + req.requestType = 'ping'; + req.requestHeader = addContentTypeHeader(req.requestHeader, data); + req.startTime = Date.now(); + getFormattedBody(data).then((res) => { + req.requestPayload = res; + that.sendRequestItem(id, req); + }); + req.response = ''; + + if (result) { + req.status = 200; + req.statusText = 'Sent'; + req.endTime = Date.now(); + req.costTime = req.endTime - (req.startTime || req.endTime); + } else { + req.status = 500; + req.statusText = 'Unknown'; + } + req.readyState = XMLHttpRequest.DONE; + that.sendRequestItem(id, req); + } else { + psLog.warn('The request object is not on navigator.sendBeacon event'); + } + return result; + }; + } +} diff --git a/src/plugins/network/proxy/common.ts b/src/plugins/network/proxy/common.ts new file mode 100644 index 00000000..495a2f21 --- /dev/null +++ b/src/plugins/network/proxy/common.ts @@ -0,0 +1,148 @@ +import { + isBlob, + isDocument, + isFile, + isFormData, + isString, + isTypedArray, + isURLSearchParams, + toStringTag, +} from 'src/utils'; +import { SpyNetwork } from 'types'; + +// File size is not recommended to exceed the MAX_SIZE, +// big size files would result negative performance impact distinctly in local-test. +export const MAX_SIZE = 1024 * 1024 * 2; +export const Reason = { + EXCEED_SIZE: 'Exceed maximum limit', +}; + +export const BINARY_FILE_VARIANT = '(file)'; +export function formatEntries(data: IterableIterator<[string, unknown]>) { + const result: [string, string][] = []; + let processor = data.next(); + while (!processor.done) { + const [key, value] = processor.value; + let variant: string; + if (isFile(value)) { + variant = BINARY_FILE_VARIANT; + } else { + variant = String(value); + } + result.push([key, variant]); + processor = data.next(); + } + return result; +} + +export function resolveUrlInfo(target: URL | string) { + try { + const { href, searchParams } = new URL(target, window.location.href); + const url = href; + const query = [...searchParams.entries()]; + // https://exp.com => "exp.com/" + // https://exp.com/ => "exp.com/" + // https://exp.com/devtools => "devtools" + // https://exp.com/devtools/ => "devtools/" + // https://exp.com/devtools?version=Mac/10.15.7 => "devtools?version=Mac/10.15.7" + // https://exp.com/devtools/?version=Mac/10.15.7 => "devtools/?version=Mac/10.15.7" + const name = href.replace(/^.*?([^/]+)(\/)*(\?.*?)?$/, '$1$2$3') || ''; + + return { + url, + name, + query, + }; + } catch (e) { + console.error(e); + return { + url: 'Unknown', + name: 'Unknown', + query: null, + }; + } +} + +export function getContentType(data: Document | RequestInit['body']) { + if (!data) return null; + if (isFormData(data)) { + return 'multipart/form-data'; + } + if (isURLSearchParams(data)) { + return 'application/x-www-form-urlencoded;charset=UTF-8'; + } + if (isDocument(data)) { + return 'application/xml'; + } + if (isBlob(data)) { + return data.type; + } + return 'text/plain;charset=UTF-8'; +} + +const CONTENT_TYPE_HEADER = 'Content-Type'; +export function addContentTypeHeader( + headers: SpyNetwork.RequestInfo['requestHeader'], + body?: Document | BodyInit | null, +) { + if (!body) return headers; + + const bodyContentType = getContentType(body); + if (!bodyContentType) return headers; + + const headerTuple = [CONTENT_TYPE_HEADER, bodyContentType] as [ + string, + string, + ]; + if (!headers) { + return [headerTuple]; + } + + for (let i = 0; i < headers.length; i++) { + const [key] = headers[i]; + if (key.toUpperCase() === CONTENT_TYPE_HEADER.toUpperCase()) { + return headers; + } + } + return [...headers, headerTuple]; +} + +/** + * FormData and USP are the only two types of request payload that can have the same key. + * SO, we store the request payload with different structure: + * - FormData / USP: [string, string][] + * - Others: string. (Tips: the body maybe serialized json string, you can try to + * deserialize it as need) + */ +export async function getFormattedBody(body?: Document | BodyInit | null) { + if (!body) { + return null; + } + if (isURLSearchParams(body) || isFormData(body)) { + return formatEntries(body.entries()); + } + if (isBlob(body)) { + return '[object Blob]'; + // try { + // const text = await body.text(); + // return text; + // } catch (e) { + // return '[object Blob]'; + // } + } + if (isTypedArray(body)) { + return '[object TypedArray]'; + } + if (isDocument(body)) { + const text = new XMLSerializer().serializeToString(body); + return text; + } + if (isString(body)) { + return body; + } + return toStringTag(body); +} + +export function isOkStatusCode(status: number) { + return status >= 200 && status < 400; +} diff --git a/src/plugins/network/proxy/fetch-proxy.ts b/src/plugins/network/proxy/fetch-proxy.ts new file mode 100644 index 00000000..4ad65dfd --- /dev/null +++ b/src/plugins/network/proxy/fetch-proxy.ts @@ -0,0 +1,166 @@ +import { + getRandomId, + isHeaders, + isObjectLike, + isString, + isURL, + psLog, +} from 'src/utils'; +import { blob2base64Async } from 'src/utils/blob'; +import { + addContentTypeHeader, + getFormattedBody, + isOkStatusCode, + MAX_SIZE, + Reason, + resolveUrlInfo, +} from './common'; +import NetworkProxyBase from './base'; + +export default class FetchProxy extends NetworkProxyBase { + private fetch: WindowOrWorkerGlobalScope['fetch'] | null = null; + + constructor() { + super(); + this.initProxyHandler(); + } + + private initProxyHandler() { + const that = this; + const originFetch = window.fetch; + + if (!originFetch) { + return; + } + this.fetch = originFetch; + window.fetch = function (input: RequestInfo | URL, init: RequestInit = {}) { + const fetchInstance = originFetch(input, init); + + const id = getRandomId(); + that.createRequest(id); + const req = that.getRequest(id); + if (req) { + let method = 'GET'; + let url: string | URL; + let requestHeader: HeadersInit | null = null; + + if (isString(input) || isURL(input)) { + // when `input` is a string + method = init.method || 'GET'; + url = input; + requestHeader = init.headers || null; + } else { + // when `input` is a `Request` object + method = input.method; + url = input.url; + requestHeader = input.headers; + } + + const urlInfo = resolveUrlInfo(url); + req.url = urlInfo.url; + req.name = urlInfo.name; + req.getData = urlInfo.query; + + req.method = method.toUpperCase(); + req.requestType = 'fetch'; + req.status = 0; + req.statusText = 'Pending'; + req.startTime = Date.now(); + req.readyState = XMLHttpRequest.UNSENT; + + if (init.credentials && init.credentials !== 'omit') { + req.withCredentials = true; + } + + if (isHeaders(requestHeader)) { + req.requestHeader = [...requestHeader.entries()]; + } else if (isObjectLike(requestHeader)) { + req.requestHeader = Object.entries(requestHeader); + } else { + req.requestHeader = requestHeader; + } + + if (req.method !== 'GET') { + req.requestHeader = addContentTypeHeader( + req.requestHeader, + init.body, + ); + getFormattedBody(init.body).then((res) => { + req.requestPayload = res; + that.sendRequestItem(id, req); + }); + } + that.sendRequestItem(id, req); + + fetchInstance + .then((res) => { + // Headers received + req.endTime = Date.now(); + req.costTime = req.endTime - (req.startTime || req.endTime); + req.status = res.status || 200; + req.statusText = res.statusText || 'Done'; + req.responseHeader = [...res.headers.entries()]; + req.readyState = XMLHttpRequest.HEADERS_RECEIVED; + that.sendRequestItem(id, req); + + // Loading ~ Done + if (!isOkStatusCode(res.status)) return ''; + const contentType = res.headers.get('content-type'); + if (contentType) { + if (contentType.includes('application/json')) { + req.responseType = 'json'; + return res.clone().text(); + } + + if ( + contentType.includes('text/html') || + contentType.includes('text/plain') + ) { + req.responseType = 'text'; + return res.clone().text(); + } + } + req.responseType = 'blob'; + return res.clone().blob(); + }) + .then(async (res) => { + switch (req.responseType) { + case 'text': + case 'json': + try { + req.response = JSON.parse(res as string); + } catch (e) { + req.response = res; + req.responseType = 'text'; + } + break; + case 'blob': + // eslint-disable-next-line no-case-declarations + const blob = res as Blob; + if (blob.size <= MAX_SIZE) { + try { + req.response = await blob2base64Async(blob); + } catch (e: any) { + req.response = await blob.text(); + psLog.error(e.message); + } + } else { + req.response = '[object Blob]'; + req.responseReason = Reason.EXCEED_SIZE; + } + break; + default: + break; + } + }) + .finally(() => { + req.readyState = XMLHttpRequest.DONE; + that.sendRequestItem(id, req); + }); + } else { + psLog.warn('The request object is not found on window.fetch event'); + } + return fetchInstance; + }; + } +} diff --git a/src/plugins/network/request-item.ts b/src/plugins/network/proxy/request-item.ts similarity index 65% rename from src/plugins/network/request-item.ts rename to src/plugins/network/proxy/request-item.ts index 385500e0..4c308757 100644 --- a/src/plugins/network/request-item.ts +++ b/src/plugins/network/proxy/request-item.ts @@ -1,7 +1,7 @@ import { SpyNetwork } from 'types'; export default class RequestItem implements SpyNetwork.RequestInfo { - id: string = ''; + id = ''; name: string = ''; @@ -11,7 +11,7 @@ export default class RequestItem implements SpyNetwork.RequestInfo { requestType: 'xhr' | 'fetch' | 'ping' = 'xhr'; - requestHeader: HeadersInit | null = null; + requestHeader: [string, string][] | null = null; status: number | string = 0; @@ -25,7 +25,7 @@ export default class RequestItem implements SpyNetwork.RequestInfo { responseType: XMLHttpRequest['responseType'] = ''; - responseHeader: Record | null = null; + responseHeader: [string, string][] | null = null; startTime: number = 0; @@ -33,9 +33,14 @@ export default class RequestItem implements SpyNetwork.RequestInfo { costTime: number = 0; - getData: Record | null = null; + getData: [string, string][] | null = null; - postData: Record | string | null = null; + /** + * @deprecated please using `requestPayload` + */ + postData: [string, string][] | string | null = null; + + requestPayload: [string, string][] | string | null = null; withCredentials: boolean = false; diff --git a/src/plugins/network/proxy/xhr-proxy.ts b/src/plugins/network/proxy/xhr-proxy.ts new file mode 100644 index 00000000..56b735c9 --- /dev/null +++ b/src/plugins/network/proxy/xhr-proxy.ts @@ -0,0 +1,239 @@ +import { + getRandomId, + isString, + toStringTag, + isArrayBuffer, + isBlob, + getObjectKeys, + psLog, +} from 'src/utils'; +import { blob2base64Async } from 'src/utils/blob'; +import NetworkProxyBase from './base'; +import RequestItem from './request-item'; +import { + MAX_SIZE, + Reason, + addContentTypeHeader, + getFormattedBody, + isOkStatusCode, + resolveUrlInfo, +} from './common'; + +declare global { + interface XMLHttpRequest { + pageSpyRequestId: string; + pageSpyRequestMethod: string; + pageSpyRequestUrl: string; + } +} +class XhrProxy extends NetworkProxyBase { + private xhrOpen: XMLHttpRequest['open'] | null = null; + + private xhrSend: XMLHttpRequest['send'] | null = null; + + private xhrSetRequestHeader: XMLHttpRequest['setRequestHeader'] | null = null; + + public constructor() { + super(); + this.initProxyHandler(); + } + + private initProxyHandler() { + const that = this; + if (!window.XMLHttpRequest) { + return; + } + const { open, send, setRequestHeader } = window.XMLHttpRequest.prototype; + this.xhrOpen = open; + this.xhrSend = send; + this.xhrSetRequestHeader = setRequestHeader; + + window.XMLHttpRequest.prototype.open = function (...args: any[]) { + const XMLReq = this; + const method = args[0]; + const url = args[1]; + const id = getRandomId(); + that.createRequest(id); + + this.pageSpyRequestId = id; + this.pageSpyRequestMethod = method; + this.pageSpyRequestUrl = url; + + XMLReq.addEventListener('readystatechange', async () => { + const req = that.getRequest(id); + if (req) { + req.readyState = XMLReq.readyState; + + switch (XMLReq.readyState) { + /* c8 ignore next */ + case XMLReq.UNSENT: + case XMLReq.OPENED: + req.status = XMLReq.status; + req.statusText = 'Pending'; + if (!req.startTime) { + req.startTime = Date.now(); + } + break; + // Header received + case XMLReq.HEADERS_RECEIVED: + req.status = XMLReq.status; + req.statusText = 'Loading'; + const header = XMLReq.getAllResponseHeaders() || ''; + const headerArr = header.trim().split(/[\r\n]+/); + req.responseHeader = headerArr.reduce((acc, cur) => { + const [headerKey, ...parts] = cur.split(': '); + acc.push([headerKey, parts.join(': ')]); + return acc; + }, [] as [string, string][]); + break; + // Loading and download + case XMLReq.LOADING: + req.status = XMLReq.status; + req.statusText = 'Loading'; + break; + // Done + case XMLReq.DONE: + req.status = XMLReq.status; + req.statusText = 'Done'; + req.endTime = Date.now(); + req.costTime = req.endTime - (req.startTime || req.endTime); + const formatResult = await that.formatResponse(XMLReq); + getObjectKeys(formatResult).forEach((key) => { + req[key] = formatResult[key]; + }); + break; + /* c8 ignore next 4 */ + default: + req.status = XMLReq.status; + req.statusText = 'Unknown'; + break; + } + that.sendRequestItem(XMLReq.pageSpyRequestId, req); + } else { + psLog.warn( + "The request object is not found on XMLHttpRequest's readystatechange event", + ); + } + }); + + return open.apply(XMLReq, args as any); + }; + + window.XMLHttpRequest.prototype.setRequestHeader = function (key, value) { + const req = that.getRequest(this.pageSpyRequestId); + if (req) { + if (!req.requestHeader) { + req.requestHeader = []; + } + req.requestHeader.push([key, value]); + } else { + psLog.warn( + "The request object is not found on XMLHttpRequest's setRequestHeader event", + ); + } + return setRequestHeader.apply(this, [key, value]); + }; + + window.XMLHttpRequest.prototype.send = function (body) { + const XMLReq = this; + const { + pageSpyRequestId, + pageSpyRequestMethod = 'GET', + pageSpyRequestUrl = '', + } = XMLReq; + const req = that.getRequest(pageSpyRequestId); + if (req) { + const urlInfo = resolveUrlInfo(pageSpyRequestUrl); + req.url = urlInfo.url; + req.name = urlInfo.name; + req.getData = urlInfo.query; + req.method = pageSpyRequestMethod.toUpperCase(); + req.requestType = 'xhr'; + req.responseType = XMLReq.responseType; + req.withCredentials = XMLReq.withCredentials; + if (req.method !== 'GET') { + req.requestHeader = addContentTypeHeader(req.requestHeader, body); + getFormattedBody(body).then((res) => { + req.requestPayload = res; + that.sendRequestItem(XMLReq.pageSpyRequestId, req); + }); + } + } else { + psLog.warn( + "The request object is not found on XMLHttpRequest's send event", + ); + } + return send.apply(XMLReq, [body]); + }; + } + + // eslint-disable-next-line class-methods-use-this + private async formatResponse(XMLReq: XMLHttpRequest) { + const result: { + response: RequestItem['response']; + responseReason: RequestItem['responseReason']; + } = { + response: '', + responseReason: null, + } as const; + + if (!isOkStatusCode(XMLReq.status)) return result; + // How to format the response is depend on XMLReq.responseType. + // The behavior is different with format fetch's response, which + // is depend on response.headers.get('content-type') + switch (XMLReq.responseType) { + case '': + case 'text': + if (isString(XMLReq.response)) { + try { + result.response = JSON.parse(XMLReq.response); + } catch (e) { + // not a JSON string + result.response = XMLReq.response; + } + } else if (typeof XMLReq.response !== 'undefined') { + result.response = toStringTag(XMLReq.response); + } + break; + case 'json': + if (typeof XMLReq.response !== 'undefined') { + result.response = XMLReq.response; + } + break; + case 'blob': + case 'arraybuffer': + if (XMLReq.response) { + let blob = XMLReq.response; + if (isArrayBuffer(blob)) { + const contentType = XMLReq.getResponseHeader('content-type'); + if (contentType) { + blob = new Blob([blob], { type: contentType }); + } + } + if (isBlob(blob)) { + if (blob.size <= MAX_SIZE) { + try { + result.response = await blob2base64Async(blob); + } catch (e: any) { + result.response = await blob.text(); + psLog.error(e.message); + } + } else { + result.response = '[object Blob]'; + result.responseReason = Reason.EXCEED_SIZE; + } + } + } + break; + case 'document': + default: + if (typeof XMLReq.response !== 'undefined') { + result.response = Object.prototype.toString.call(XMLReq.response); + } + break; + } + return result; + } +} + +export default XhrProxy; diff --git a/src/plugins/page.ts b/src/plugins/page.ts index 4ede1b17..cf7f5316 100644 --- a/src/plugins/page.ts +++ b/src/plugins/page.ts @@ -3,10 +3,10 @@ import type PageSpyPlugin from 'src/plugins/index'; import { makeMessage, DEBUG_MESSAGE_TYPE } from 'src/utils/message'; export default class PagePlugin implements PageSpyPlugin { - name = 'PagePlugin'; + public name = 'PagePlugin'; // eslint-disable-next-line class-methods-use-this - onCreated() { + public onCreated() { window.addEventListener('load', () => { const msg = PagePlugin.collectHtml(); SocketStore.broadcastMessage(msg); @@ -20,7 +20,7 @@ export default class PagePlugin implements PageSpyPlugin { }); } - static collectHtml() { + private static collectHtml() { const originHtml = document.documentElement.outerHTML; const msg = makeMessage(DEBUG_MESSAGE_TYPE.PAGE, { html: originHtml, diff --git a/src/plugins/storage.ts b/src/plugins/storage.ts index 642ca728..4d76044c 100644 --- a/src/plugins/storage.ts +++ b/src/plugins/storage.ts @@ -4,10 +4,10 @@ import type PageSpyPlugin from './index'; import socketStore from '../utils/socket'; export class StoragePlugin implements PageSpyPlugin { - name = 'StoragePlugin'; + public name = 'StoragePlugin'; // eslint-disable-next-line class-methods-use-this - onCreated() { + public onCreated() { const { sendStorageItem, initStorageProxy } = StoragePlugin; const local = { ...localStorage }; Object.keys(local).forEach((name) => { @@ -53,7 +53,12 @@ export class StoragePlugin implements PageSpyPlugin { initStorageProxy(); } - static initStorageProxy() { + private static sendStorageItem(info: Omit) { + const data = makeMessage(DEBUG_MESSAGE_TYPE.STORAGE, info); + socketStore.broadcastMessage(data); + } + + private static initStorageProxy() { const { getStorageType, sendStorageItem } = StoragePlugin; const { clear, removeItem, setItem } = Storage.prototype; @@ -77,12 +82,7 @@ export class StoragePlugin implements PageSpyPlugin { }; } - static sendStorageItem(info: Omit) { - const data = makeMessage(DEBUG_MESSAGE_TYPE.STORAGE, info); - socketStore.broadcastMessage(data); - } - - static getStorageType(ins: Storage): SpyStorage.DataType { + private static getStorageType(ins: Storage): SpyStorage.DataType { if (ins === localStorage) return 'local'; if (ins === sessionStorage) return 'session'; return ins.constructor.name as any; diff --git a/src/plugins/system/index.ts b/src/plugins/system/index.ts index 0c467e1d..9c2678ea 100644 --- a/src/plugins/system/index.ts +++ b/src/plugins/system/index.ts @@ -7,7 +7,6 @@ import '../../deps/modernizr'; import { SpySystem } from 'types'; import { computeResult } from './feature'; -/* c8 ignore start */ window.Modernizr.addTest( 'finally', Modernizr.promises && !!Promise.prototype.finally, @@ -16,17 +15,16 @@ window.Modernizr.addTest( 'iframe', Modernizr.sandbox && Modernizr.seamless && Modernizr.srcdoc, ); -/* c8 ignore stop */ export default class SystemPlugin implements PageSpyPlugin { - name: string; + public name: string; - constructor() { + public constructor() { this.name = 'SystemPlugin'; } // eslint-disable-next-line class-methods-use-this - async onCreated() { + public async onCreated() { const id = getRandomId(); const features = await computeResult(); socketStore.broadcastMessage( diff --git a/src/utils/atom.ts b/src/utils/atom.ts index b9b5bc77..ea534a1b 100644 --- a/src/utils/atom.ts +++ b/src/utils/atom.ts @@ -13,12 +13,28 @@ import { } from './index'; class Atom { - store: Record = {}; + private store: Record = {}; + + public getStore() { + return this.store; + } + + public resetStore() { + this.store = {}; + } // { __atomId: instanceId } - instanceStore: Record = {}; + private instanceStore: Record = {}; - transformToAtom(data: any): any { + public getInstanceStore() { + return this.instanceStore; + } + + public resetInstanceStore() { + this.instanceStore = {}; + } + + public transformToAtom(data: any): any { const { value, ok } = makePrimitiveValue(data); if (ok) { return { @@ -30,21 +46,7 @@ class Atom { return this.add(data); } - add(data: any, insId: string = ''): SpyAtom.Overview { - const id = getRandomId(); - let instanceId = id; - // must provide the instance id if the `isPrototype(data)` return true, - // or else will occur panic when access the property along the proto chain - if (isPrototype(data)) { - instanceId = insId; - } - this.store[id] = data; - this.instanceStore[id] = instanceId; - const name = Atom.getSemanticValue(data); - return Atom.getAtomOverview({ atomId: id, value: name, instanceId }); - } - - get(id: string) { + public get(id: string) { const cacheData = this.store[id]; const instanceId = this.instanceStore[id]; if (!cacheData) return null; @@ -70,14 +72,28 @@ class Atom { } /* c8 ignore start */ - getOrigin(id: string) { + public getOrigin(id: string) { const value = this.store[id]; if (!value) return null; return value; } /* c8 ignore stop */ - static getAtomOverview({ + public add(data: any, insId: string = ''): SpyAtom.Overview { + const id = getRandomId(); + let instanceId = id; + // must provide the instance id if the `isPrototype(data)` return true, + // or else will occur panic when access the property along the proto chain + if (isPrototype(data)) { + instanceId = insId; + } + this.store[id] = data; + this.instanceStore[id] = instanceId; + const name = Atom.getSemanticValue(data); + return Atom.getAtomOverview({ atomId: id, value: name, instanceId }); + } + + private static getAtomOverview({ instanceId = '', atomId, value, @@ -96,7 +112,7 @@ class Atom { }; } - static getSemanticValue(data: any) { + private static getSemanticValue(data: any) { if (isPlainObject(data)) { return 'Object {...}'; } diff --git a/src/utils/blob.ts b/src/utils/blob.ts index c45421b4..1be840ed 100644 --- a/src/utils/blob.ts +++ b/src/utils/blob.ts @@ -1,11 +1,12 @@ -export const blob2base64 = (blob: Blob, cb: (data: any) => void) => { - const fr = new FileReader(); - fr.onload = (e) => { - cb(e.target?.result); - }; - /* c8 ignore next 3 */ - fr.onerror = () => { - cb(new Error('blob2convert: can not convert')); - }; - fr.readAsDataURL(blob); -}; +export const blob2base64Async = (blob: Blob) => + new Promise((resolve, reject) => { + const fr = new FileReader(); + fr.onload = (e) => { + resolve(e.target?.result); + }; + /* c8 ignore next 3 */ + fr.onerror = () => { + reject(new Error('blob2base64Async: can not convert')); + }; + fr.readAsDataURL(blob); + }); diff --git a/src/utils/index.ts b/src/utils/index.ts index fe84722e..c1bf806f 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -5,21 +5,6 @@ export function getObjectKeys>(obj: T) { return Object.keys(obj) as (keyof T)[]; } -/* c8 ignore start */ -export function getContentType(data?: BodyInit | null) { - if (data instanceof Blob) { - return data.type; - } - if (data instanceof FormData) { - return 'multipart/form-data'; - } - if (data instanceof URLSearchParams) { - return 'application/x-www-form-urlencoded;charset=UTF-8'; - } - return 'text/plain;charset=UTF-8'; -} -/* c8 ignore stop */ - export function toStringTag(value: any) { return Object.prototype.toString.call(value); } @@ -28,62 +13,110 @@ export function hasOwnProperty(target: Object, key: string) { return Object.prototype.hasOwnProperty.call(target, key); } -export function getPrototypeName(value: any) { - return toStringTag(value).replace(/\[object (.*)\]/, '$1'); +export function isString(value: unknown): value is string { + return typeof value === 'string'; } -export function isString(value: any) { - return toStringTag(value) === '[object String]'; + +export function isNumber(value: unknown): value is number { + return typeof value === 'number'; } -export function isNumber(value: any) { - return toStringTag(value) === '[object Number]'; +export function isBigInt(value: unknown): value is bigint { + return toStringTag(value) === '[object BigInt]'; } -export function isArray(value: any) { - return toStringTag(value) === '[object Array]'; +export function isArray(value: unknown): value is unknown[] { + return value instanceof Array; } -export function isArrayLike(value: any) { +export function isArrayLike( + value: unknown, +): value is NodeList | HTMLCollection { return value instanceof NodeList || value instanceof HTMLCollection; } -export function isObjectLike(value: any) { +export function isObjectLike(value: unknown): value is Record { return typeof value === 'object' && value !== null; } -export function isBigInt(value: any) { - return toStringTag(value) === '[object BigInt]'; -} - -export function isPlainObject(value: any) { +export function isPlainObject( + value: unknown, +): value is Record { if (!isObjectLike(value) || toStringTag(value) !== '[object Object]') { return false; } return true; - // let proto = value; - // while (Object.getPrototypeOf(proto) !== null) { - // proto = Object.getPrototypeOf(proto); - // } - // return Object.getPrototypeOf(value) === proto; } -export function isPrototype(data: any) { +export function isPrototype(value: unknown): value is Object { if ( - isObjectLike(data) && - hasOwnProperty(data, 'constructor') && - typeof data.constructor === 'function' + isObjectLike(value) && + hasOwnProperty(value, 'constructor') && + typeof value.constructor === 'function' ) { return true; } return false; } -export function isBlob(data: any) { - return toStringTag(data) === '[object Blob]'; +export function isBlob(value: unknown): value is Blob { + return value instanceof Blob; +} + +export function isArrayBuffer(value: unknown): value is ArrayBuffer { + return value instanceof ArrayBuffer; +} + +export function isURLSearchParams(value: unknown): value is URLSearchParams { + return value instanceof URLSearchParams; } -export function isArrayBuffer(data: any) { - return toStringTag(data) === '[object ArrayBuffer]'; +export function isFormData(value: unknown): value is FormData { + return value instanceof FormData; +} + +export function isFile(value: unknown): value is File { + return value instanceof File; +} + +export function isHeaders(value: unknown): value is Headers { + return value instanceof Headers; +} + +export function isDocument(value: unknown): value is Document { + return value instanceof Document; +} + +export function isURL(value: unknown): value is URL { + return value instanceof URL; +} + +type TypedArray = + | Int8Array + | Uint8Array + | Uint8ClampedArray + | Int16Array + | Uint16Array + | Int32Array + | Uint32Array + | Float32Array + | Float64Array + | BigInt64Array + | BigUint64Array; +export function isTypedArray(value: unknown): value is TypedArray { + return [ + Int8Array, + Uint8Array, + Uint8ClampedArray, + Int16Array, + Uint16Array, + Int32Array, + Uint32Array, + Float32Array, + Float64Array, + BigInt64Array, + BigUint64Array, + ].includes(Object.getPrototypeOf(value).constructor); } interface PrimitiveResult { @@ -91,13 +124,13 @@ interface PrimitiveResult { value: any; } -const stringify = (value: string) => `${value}`; +const stringify = (value: any) => `${value}`; const primitive = (value: any) => ({ ok: true, value, }); -export function makePrimitiveValue(value: any): PrimitiveResult { +export function makePrimitiveValue(value: unknown): PrimitiveResult { if (value === undefined) { return primitive(stringify(value)); } @@ -156,3 +189,25 @@ export function getValueType(value: any) { } return typeof value; } + +/** + * The methods are used for internal calls. + */ +interface PSLog { + log(message: string): void; + info(message: string): void; + warn(message: string): void; + error(message: string): void; +} +export const psLog = (['log', 'info', 'error', 'warn'] as const).reduce( + (result, method) => { + // eslint-disable-next-line no-param-reassign + result[method] = (message: string) => { + console[method]( + `[PageSpy] [${method.toLocaleUpperCase()}]: ${message.toString()}`, + ); + }; + return result; + }, + {} as PSLog, +); diff --git a/src/utils/socket.ts b/src/utils/socket.ts index 6b536a29..2b58f693 100644 --- a/src/utils/socket.ts +++ b/src/utils/socket.ts @@ -1,4 +1,4 @@ -import { getRandomId, stringifyData } from 'src/utils'; +import { getRandomId, psLog, stringifyData } from 'src/utils'; import { DEBUG_MESSAGE_TYPE, makeMessage, @@ -32,27 +32,23 @@ interface GetterMember { export class SocketStore { // websocket instance - socket: WebSocket | null = null; + private socket: WebSocket | null = null; - socketUrl: string = ''; + public getSocket() { + return this.socket; + } - socketConnection: SpySocket.Connection | null = null; + private socketUrl: string = ''; - timer: number | null = null; + private socketConnection: SpySocket.Connection | null = null; - reconnectTimes = 3; + private timer: number | null = null; // messages store - messages: (SpySocket.BrodcastEvent | SpySocket.UnicastEvent)[] = []; - - // Don't try to reconnect if error occupied - reconnectable: boolean = true; - - // indicated connected whether or not - connectionStatus: boolean = false; + private messages: (SpySocket.BrodcastEvent | SpySocket.UnicastEvent)[] = []; // events center - events: Record = { + private events: Record = { refresh: [], debug: [], 'atom-detail': [], @@ -60,9 +56,17 @@ export class SocketStore { 'debugger-online': [], }; + // Don't try to reconnect if error occupied + private reconnectable: boolean = true; + + private reconnectTimes = 3; + // Don't try to reconnect and close immediately // when user refresh the page. - closeImmediately: boolean = false; + private closeImmediately: boolean = false; + + // indicated connected whether or not + public connectionStatus: boolean = false; constructor() { this.addListener('debug', SocketStore.handleDebugger); @@ -71,7 +75,7 @@ export class SocketStore { this.addListener('debugger-online', this.handleFlushBuffer); } - init(url: string) { + public init(url: string) { try { if (!url) { throw Error('[PageSpy] WebSocket url cannot be empty'); @@ -95,13 +99,38 @@ export class SocketStore { } } - connectOnline() { + public addListener( + type: SpyMessage.InteractiveType, + fn: SocketEventCallback, + ) { + /* c8 ignore next 3 */ + if (!this.events[type]) { + this.events[type] = []; + } + this.events[type].push(fn); + } + + public broadcastMessage( + msg: SpyMessage.MessageItem, + isCache: boolean = false, + ) { + const message = makeBroadcastMessage(msg); + this.send(message, isCache); + } + + public close() { + this.clearPing(); + this.closeImmediately = true; + this.socket?.close(); + } + + private connectOnline() { this.connectionStatus = true; this.reconnectTimes = 3; this.pingConnect(); } - connectOffline() { + private connectOffline() { this.socket = null; this.connectionStatus = false; this.socketConnection = null; @@ -111,7 +140,7 @@ export class SocketStore { this.tryReconnect(); } - tryReconnect() { + private tryReconnect() { if (!this.reconnectable) { sessionStorage.setItem( ROOM_SESSION_KEY, @@ -124,12 +153,12 @@ export class SocketStore { this.init(this.socketUrl); } /* c8 ignore start */ else { this.reconnectable = false; - console.log('[PageSpy] Reconnect failed.'); + psLog.warn('Websocket reconnect failed.'); } /* c8 ignore stop */ } - pingConnect() { + private pingConnect() { /* c8 ignore start */ this.timer = window.setInterval(() => { if (this.socket?.readyState !== WebSocket.OPEN) return; @@ -141,7 +170,7 @@ export class SocketStore { /* c8 ignore stop */ } - clearPing() { + private clearPing() { if (this.timer) { window.clearInterval(this.timer); } @@ -189,15 +218,7 @@ export class SocketStore { } } - addListener(type: SpyMessage.InteractiveType, fn: SocketEventCallback) { - /* c8 ignore next 3 */ - if (!this.events[type]) { - this.events[type] = []; - } - this.events[type].push(fn); - } - - dispatchEvent(type: SpyMessage.InteractiveType, data: SocketEvent) { + private dispatchEvent(type: SpyMessage.InteractiveType, data: SocketEvent) { this.events[type].forEach((fn) => { fn.call(this, data, (d: SpyMessage.MessageItem) => { this.unicastMessage(d, data.from); @@ -205,17 +226,15 @@ export class SocketStore { }); } - unicastMessage(msg: SpyMessage.MessageItem, to: SpySocket.Connection) { + private unicastMessage( + msg: SpyMessage.MessageItem, + to: SpySocket.Connection, + ) { const message = makeUnicastMessage(msg, this.socketConnection!, to); this.send(message); } - broadcastMessage(msg: SpyMessage.MessageItem, isCache: boolean = false) { - const message = makeBroadcastMessage(msg); - this.send(message, isCache); - } - - handleFlushBuffer(message: SocketEvent<{ latestId: string }>) { + private handleFlushBuffer(message: SocketEvent<{ latestId: string }>) { const { latestId } = message.source.data; const msgIndex = this.messages.findIndex( @@ -239,8 +258,8 @@ export class SocketStore { /* c8 ignore stop */ } - // run excutable code which received from remote and send back the result - static handleDebugger( + // run executable code which received from remote and send back the result + private static handleDebugger( { source }: SocketEvent, reply: (data: any) => void, ) { @@ -280,7 +299,7 @@ export class SocketStore { } } - static handleResolveAtom( + private static handleResolveAtom( { source }: SocketEvent, reply: (data: any) => void, ) { @@ -292,7 +311,7 @@ export class SocketStore { } } - static handleAtomPropertyGetter( + private static handleAtomPropertyGetter( { source }: SocketEvent, reply: (data: any) => void, ) { @@ -316,7 +335,7 @@ export class SocketStore { } } - send(msg: SpySocket.ClientEvent, isCache: boolean = false) { + private send(msg: SpySocket.ClientEvent, isCache: boolean = false) { if (this.connectionStatus) { /* c8 ignore start */ try { @@ -340,12 +359,6 @@ export class SocketStore { ); } } - - close() { - this.clearPing(); - this.closeImmediately = true; - this.socket?.close(); - } } export default new SocketStore(); diff --git a/tests/init.test.ts b/tests/init.test.ts index 908ff435..61343004 100644 --- a/tests/init.test.ts +++ b/tests/init.test.ts @@ -80,11 +80,13 @@ describe('new PageSpy([config])', () => { expect(consoleKey.map((i) => console[i])).toEqual(originConsole); const cPlugin = new ConsolePlugin(); + // @ts-ignore expect(Object.keys(cPlugin.console)).toHaveLength(0); // changed! cPlugin.onCreated(); expect(consoleKey.map((i) => console[i])).not.toEqual(originConsole); + // @ts-ignore expect(Object.keys(cPlugin.console)).toHaveLength(4); }); diff --git a/tests/plugins/network.test.ts b/tests/plugins/network.test.ts deleted file mode 100644 index 842bca85..00000000 --- a/tests/plugins/network.test.ts +++ /dev/null @@ -1,178 +0,0 @@ -import NetworkPlugin from 'src/plugins/network'; -import startServer from '../server/index'; - -const stopServer = startServer(); - -const { - open: originOpen, - setRequestHeader: originSetRequestHeader, - send: originSend, -} = window.XMLHttpRequest.prototype; -const originFetch = window.fetch; -const { sendBeacon: originSendBeacon } = window.navigator; - -afterEach(() => { - jest.restoreAllMocks(); - - window.XMLHttpRequest.prototype.open = originOpen; - window.XMLHttpRequest.prototype.setRequestHeader = originSetRequestHeader; - window.XMLHttpRequest.prototype.send = originSend; - window.fetch = originFetch; - window.navigator.sendBeacon = originSendBeacon; -}); -afterAll(stopServer); - -describe('Network plugin', () => { - it('Wrap XMLHttpRequest prototype and add `onreadystatechange` on instance', (done) => { - const fakeUrl = 'http://localhost:6677/posts'; - const openSpy = jest.spyOn(XMLHttpRequest.prototype, 'open'); - const setHeaderSpy = jest.spyOn( - XMLHttpRequest.prototype, - 'setRequestHeader', - ); - const sendSpy = jest.spyOn(XMLHttpRequest.prototype, 'send'); - - new NetworkPlugin().xhrProxy(); - expect(XMLHttpRequest.prototype.open).not.toBe(openSpy); - expect(XMLHttpRequest.prototype.setRequestHeader).not.toBe(setHeaderSpy); - expect(XMLHttpRequest.prototype.send).not.toBe(sendSpy); - - const xhr = new XMLHttpRequest(); - xhr.responseType = 'text'; - const body = { title: 'PageSpy', body: 'XHR Test' }; - const bodyStringify = JSON.stringify(body); - xhr.open('POST', fakeUrl); - xhr.setRequestHeader('Content-Type', 'application/json; charset=utf-8'); - xhr.send(bodyStringify); - - expect(xhr.onreadystatechange).not.toBeFalsy(); - expect(openSpy).toHaveBeenCalledWith('POST', fakeUrl); - expect(setHeaderSpy).toHaveBeenCalledWith( - 'Content-Type', - 'application/json; charset=utf-8', - ); - expect(sendSpy).toHaveBeenCalledWith(bodyStringify); - expect(sendSpy).toHaveBeenCalledTimes(1); - - xhr.onload = () => { - expect(JSON.parse(xhr.response)).toEqual(expect.objectContaining(body)); - done(); - }; - }); - - it('XHR json responseType', (done) => { - new NetworkPlugin().xhrProxy(); - - const fakeUrl = 'http://localhost:6677/posts/1'; - const xhr = new XMLHttpRequest(); - xhr.responseType = 'json'; - xhr.open('GET', fakeUrl); - xhr.send(); - - xhr.onload = () => { - expect(xhr.response).toMatchObject( - expect.objectContaining({ - id: expect.any(Number), - title: expect.any(String), - }), - ); - done(); - }; - }); - - it('XHR blob responseType', (done) => { - new NetworkPlugin().xhrProxy(); - - const fakeUrl = 'http://localhost:6677/posts/1'; - const xhr = new XMLHttpRequest(); - xhr.responseType = 'blob'; - xhr.open('GET', fakeUrl); - xhr.send(); - - xhr.onload = () => { - const fr = new FileReader(); - fr.onload = (e) => { - expect(JSON.parse(e.target?.result as string)).toMatchObject( - expect.objectContaining({ - id: expect.any(Number), - title: expect.any(String), - }), - ); - done(); - }; - fr.readAsText(xhr.response); - }; - }); - - it('XHR arrarybuffer responseType', (done) => { - new NetworkPlugin().xhrProxy(); - - const fakeUrl = 'http://localhost:6677/posts/1'; - const xhr = new XMLHttpRequest(); - xhr.responseType = 'arraybuffer'; - xhr.open('GET', fakeUrl); - xhr.send(); - - xhr.onload = () => { - const result = new TextDecoder().decode(xhr.response); - expect(JSON.parse(result)).toMatchObject( - expect.objectContaining({ - id: expect.any(Number), - title: expect.any(String), - }), - ); - done(); - }; - }); - - it('Wrap fetch request', async () => { - const fetchSpy = jest.spyOn(window, 'fetch'); - expect(window.fetch).toBe(fetchSpy); - new NetworkPlugin().fetchProxy(); - expect(window.fetch).not.toBe(fetchSpy); - - const fakeUrl = 'http://localhost:6677/posts/1'; - console.log(window.location.href); - - const res = await fetch(fakeUrl, { - method: 'GET', - credentials: 'include', - headers: { - 'Content-Type': 'application/json', - }, - }); - const data = await res.json(); - expect(data).toMatchObject({ - id: expect.any(Number), - title: expect.any(String), - }); - - const fakeRequest = new Request(fakeUrl, { - method: 'GET', - credentials: 'include', - headers: { - 'Content-Type': 'application/json', - }, - }); - const res2 = await fetch(fakeRequest); - const data2 = await res2.json(); - expect(data2).toMatchObject({ - id: expect.any(Number), - title: expect.any(String), - }); - }); - - it('sendBeacon request', async () => { - const sendBeaconSpy = jest.spyOn(window.navigator, 'sendBeacon'); - expect(window.navigator.sendBeacon).toBe(sendBeaconSpy); - new NetworkPlugin().sendBeaconProxy(); - expect(window.navigator.sendBeacon).not.toBe(sendBeaconSpy); - - const fakeUrl = 'http://localhost:6677/posts?search-for=test'; - const body = { title: 'PageSpy', body: 'XHR Test' }; - const bodyStringify = JSON.stringify(body); - navigator.sendBeacon(fakeUrl, bodyStringify); - - expect(sendBeaconSpy).toHaveBeenCalledWith(fakeUrl, bodyStringify); - }); -}); diff --git a/tests/plugins/network/beacon-proxy.test.ts b/tests/plugins/network/beacon-proxy.test.ts new file mode 100644 index 00000000..f1caf50d --- /dev/null +++ b/tests/plugins/network/beacon-proxy.test.ts @@ -0,0 +1,103 @@ +import NetworkPlugin from 'src/plugins/network'; +import startServer from '../../server/index'; +import { computeRequestMapInfo } from './util'; + +const port = 6699; +const apiPrefix = `http://localhost:${port}`; +const stopServer = startServer(port); +afterAll(stopServer); + +const { sendBeacon: originSendBeacon } = window.navigator; +afterEach(() => { + jest.restoreAllMocks(); + window.navigator.sendBeacon = originSendBeacon; +}); + +describe('navigator.sendBeacon proxy', () => { + it('Do nothing if not exist navigator.sendBeacon', () => { + const originBeacon = window.navigator.sendBeacon; + Object.defineProperty(navigator, 'sendBeacon', { + value: undefined, + writable: true, + }); + new NetworkPlugin().onCreated(); + expect(window.navigator.sendBeacon).toBe(undefined); + window.navigator.sendBeacon = originBeacon; + }); + it('Wrap the navigator.sendBeacon', async () => { + const sendBeaconSpy = jest.spyOn(window.navigator, 'sendBeacon'); + expect(window.navigator.sendBeacon).toBe(sendBeaconSpy); + new NetworkPlugin().onCreated(); + expect(window.navigator.sendBeacon).not.toBe(sendBeaconSpy); + }); + + it("The origin's method will be called", () => { + const spyBeacon = jest.spyOn(navigator, 'sendBeacon'); + const api = `${apiPrefix}/posts`; + const body = new FormData(); + navigator.sendBeacon(api, body); + + expect(spyBeacon).toBeCalledTimes(1); + }); + + it('Mock the truthy / falsy result', () => { + jest.spyOn(window.navigator, 'sendBeacon').mockImplementation(() => { + return true; + }); + const np = new NetworkPlugin(); + np.onCreated(); + const { beaconProxy } = np; + window.navigator.sendBeacon(`${apiPrefix}/posts`); + + const { size, freezedRequests } = computeRequestMapInfo(beaconProxy); + const current = Object.values(freezedRequests); + expect(size).toBe(1); + expect(current[0]?.status).toBe(200); + }); + + it('Mock the falsy result', () => { + jest.spyOn(window.navigator, 'sendBeacon').mockImplementation(() => { + return false; + }); + const np = new NetworkPlugin(); + np.onCreated(); + const { beaconProxy } = np; + window.navigator.sendBeacon(`${apiPrefix}/posts`); + + const { size, freezedRequests } = computeRequestMapInfo(beaconProxy); + const current = Object.values(freezedRequests); + expect(size).toBe(1); + expect(current[0]?.status).toBe(500); + }); + + it('The SDK record the request information', () => { + const np = new NetworkPlugin(); + np.onCreated(); + const { beaconProxy } = np; + expect(beaconProxy).not.toBe(null); + expect(computeRequestMapInfo(beaconProxy).size).toBe(0); + + const count = 5; + Array.from({ length: count }).forEach((_, index) => { + navigator.sendBeacon(new URL(`${apiPrefix}/posts/${index}`)); + }); + expect(computeRequestMapInfo(beaconProxy).size).toBe(count); + }); + + it('The cached request items will be freed when no longer needed', async () => { + jest.useFakeTimers(); + const np = new NetworkPlugin(); + np.onCreated(); + const { beaconProxy } = np; + expect(beaconProxy).not.toBe(null); + expect(computeRequestMapInfo(beaconProxy).size).toBe(0); + + navigator.sendBeacon(`${apiPrefix}/posts`); + + expect(computeRequestMapInfo(beaconProxy).size).toBe(1); + jest.advanceTimersByTime(3500); + + // The previous request item now be freed after 3s. + expect(computeRequestMapInfo(beaconProxy).size).toBe(0); + }); +}); diff --git a/tests/plugins/network/common.test.ts b/tests/plugins/network/common.test.ts new file mode 100644 index 00000000..0098925b --- /dev/null +++ b/tests/plugins/network/common.test.ts @@ -0,0 +1,198 @@ +import { + BINARY_FILE_VARIANT, + addContentTypeHeader, + formatEntries, + getContentType, + getFormattedBody, + resolveUrlInfo, +} from 'src/plugins/network/proxy/common'; +import { toStringTag } from 'src/utils'; + +describe('Network utilities', () => { + // format the USP and FormData data which be used in request payload, + // not all entries which implement the IterableIterator + it('formatEntries()', () => { + // FormData + const fd_data = new FormData(); + fd_data.append('color', 'grey'); + fd_data.append('color', 'slate'); + fd_data.append( + 'color', + new File(['How to get the right text color'], 'Colors.pdf'), + ); + const fd_data_format = formatEntries(fd_data.entries()); + const fd_data_result = [ + ['color', 'grey'], + ['color', 'slate'], + ['color', BINARY_FILE_VARIANT], + ]; + expect(fd_data_format).toEqual(fd_data_result); + + // USP + const usp_data = new URLSearchParams('color=red&color=green&color=blue'); + const usp_data_format = formatEntries(usp_data.entries()); + const usp_data_result = [ + ['color', 'red'], + ['color', 'green'], + ['color', 'blue'], + ]; + expect(usp_data_format).toEqual(usp_data_result); + }); + + describe('resolveUrlInfo()', () => { + it('Normal location context', () => { + const urlInfo = resolveUrlInfo('./foo?bar=bar'); + expect(urlInfo).toEqual({ + url: 'http://localhost/foo?bar=bar', + name: 'foo?bar=bar', + query: [['bar', 'bar']], + }); + }); + it('Unknown wired location context', () => { + const originLocation = window.location; + Object.defineProperty(window, 'location', { + value: { + href: null, + }, + writable: true, + }); + const urlInfo = resolveUrlInfo('./foo?bar=bar'); + expect(urlInfo).toEqual({ + url: 'Unknown', + name: 'Unknown', + query: null, + }); + window.location = originLocation; + }); + it('Format `Name` field', () => { + [ + { received: 'https://exp.com', expected: 'exp.com/' }, + { received: 'https://exp.com/', expected: 'exp.com/' }, + { received: 'https://exp.com/devtools', expected: 'devtools' }, + { received: 'https://exp.com/devtools/', expected: 'devtools/' }, + { + received: 'https://exp.com/devtools?version=Mac/10.15.7', + expected: 'devtools?version=Mac/10.15.7', + }, + { + received: 'https://exp.com/devtools/?version=Mac/10.15.7', + expected: 'devtools/?version=Mac/10.15.7', + }, + ].forEach(({ received, expected }) => { + expect(resolveUrlInfo(received).name).toBe(expected); + }); + }); + }); + + it('getContentType()', () => { + [ + { + received: null, + expected: null, + }, + { + received: new DOMParser().parseFromString('
', 'text/xml'), + expected: 'application/xml', + }, + { + received: new Blob(['Hello PageSpy'], { type: 'text/plain' }), + expected: 'text/plain', + }, + { + received: new Uint8Array([1, 2, 3, 4, 5]).buffer, + expected: 'text/plain;charset=UTF-8', + }, + { + received: new FormData(), + expected: 'multipart/form-data', + }, + { + received: new URLSearchParams('foo=foo'), + expected: 'application/x-www-form-urlencoded;charset=UTF-8', + }, + { + received: '{"Hello":"PageSpy"}', + expected: 'text/plain;charset=UTF-8', + }, + ].forEach(({ received, expected }) => { + expect(getContentType(received)).toBe(expected); + }); + }); + + it('addContentTypeHeader()', () => { + type Headers = [string, string][]; + const headers_null = null; + const headers_no_ct: Headers = [['X-Name', 'PageSpy']]; + const headers_upper_ct: Headers = [ + ['Content-Type', 'text/plain;charset=UTF-8'], + ]; + const headers_lower_ct: Headers = [ + ['content-type', 'text/plain;charset=UTF-8'], + ]; + + const body_null = null; + const body_entity = 'String body'; + expect(addContentTypeHeader(headers_null, body_null)).toBe(headers_null); + expect(addContentTypeHeader(headers_null, body_entity)).toEqual( + headers_upper_ct, + ); + expect(addContentTypeHeader(headers_no_ct, body_entity)).toEqual([ + ...headers_no_ct, + ...headers_upper_ct, + ]); + expect(addContentTypeHeader(headers_lower_ct, body_entity)).toEqual( + headers_lower_ct, + ); + }); + + it('getFormattedBody()', async () => { + // Null + const null_body = null; + const null_body_format = await getFormattedBody(null_body); + const null_body_expect = null; + expect(null_body_format).toBe(null_body_expect); + + // String + const string_body = '[1,2,3,4]'; + const string_body_format = await getFormattedBody(string_body); + const string_body_expect = string_body; + expect(string_body_format).toBe(string_body_expect); + + // Document + const doc_body = new DOMParser().parseFromString( + '
', + 'text/html', + ); + const doc_body_format = await getFormattedBody(doc_body); + const doc_body_expect = new XMLSerializer().serializeToString(doc_body); + expect(doc_body_format).toBe(doc_body_expect); + + // BufferSource + const buffer_body = new Uint8Array([1, 2, 3, 4]); + const buffer_body_format = await getFormattedBody(buffer_body); + const buffer_body_result = '[object TypedArray]'; + expect(buffer_body_format).toBe(buffer_body_result); + + // File / Blob + const blob_body = new Blob(['1234'], { type: 'text/plain' }); + const blob_body_format = await getFormattedBody(blob_body); + const blob_body_result = '[object Blob]'; + expect(blob_body_format).toBe(blob_body_result); + + // USP + const usp_body = new URLSearchParams( + 'like=camping&like=fishing&like=driving', + ); + const usp_body_format = await getFormattedBody(usp_body); + const usp_body_result = formatEntries(usp_body.entries()); + expect(usp_body_format).toEqual(usp_body_result); + + // FormData + const fd_body = new FormData(); + fd_body.append('color', 'lightgreen'); + fd_body.append('color', 'slate'); + const fd_body_format = await getFormattedBody(fd_body); + const fd_body_result = formatEntries(fd_body.entries()); + expect(fd_body_format).toEqual(fd_body_result); + }); +}); diff --git a/tests/plugins/network/fetch-proxy.test.ts b/tests/plugins/network/fetch-proxy.test.ts new file mode 100644 index 00000000..6395bde3 --- /dev/null +++ b/tests/plugins/network/fetch-proxy.test.ts @@ -0,0 +1,140 @@ +import NetworkPlugin from 'src/plugins/network'; +import startServer from '../../server/index'; +import data from '../../server/data.json'; +import { Reason } from 'src/plugins/network/proxy/common'; +import { computeRequestMapInfo } from './util'; + +const port = 6688; +const apiPrefix = `http://localhost:${port}`; +const stopServer = startServer(port); +afterAll(stopServer); + +const originFetch = window.fetch; +const sleep = (t = 100) => new Promise((r) => setTimeout(r, t)); + +afterEach(() => { + jest.restoreAllMocks(); + window.fetch = originFetch; +}); + +describe('window.fetch proxy', () => { + it('Do nothing if not exist window.fetch', () => { + Object.defineProperty(window, 'fetch', { + value: undefined, + writable: true, + }); + new NetworkPlugin().onCreated(); + expect(window.fetch).toBe(undefined); + }); + it('Wrap fetch request', () => { + const fetchSpy = jest.spyOn(window, 'fetch'); + expect(window.fetch).toBe(fetchSpy); + + new NetworkPlugin().onCreated(); + expect(window.fetch).not.toBe(fetchSpy); + }); + + it('The origin fetch will be called and get response', async () => { + const spyFetch = jest.spyOn(window, 'fetch'); + new NetworkPlugin().onCreated(); + + // fetch(url, init) + const url = `${apiPrefix}/posts`; + const res1 = await fetch(url, { + method: 'GET', + headers: { + 'X-Name': 'PageSpy', + }, + credentials: 'include', + }); + const json1 = await res1.json(); + expect(spyFetch).toBeCalledTimes(1); + expect(json1).toEqual(data); + + // fetch(new Request()) + const res2 = await fetch(new Request(url)); + const json2 = await res2.json(); + expect(spyFetch).toBeCalledTimes(2); + expect(json2).toEqual(data); + }); + + it('Request different type response', async () => { + // text/plain + new NetworkPlugin().onCreated(); + const textUrl = `${apiPrefix}/plain-text`; + const res1 = await (await fetch(textUrl)).clone().text(); + expect(res1).toEqual(expect.stringContaining('Hello PageSpy')); + + // blob + const blobUrl = `${apiPrefix}/blob`; + const res2 = await fetch(blobUrl); + expect(res2.status).toBe(200); + + // text/html + const htmlUrl = `${apiPrefix}/html`; + const res3 = await (await fetch(htmlUrl)).clone().text(); + const doc = new DOMParser().parseFromString(res3, 'text/html'); + const title = doc.querySelector('#app'); + expect(title).toBeInstanceOf(HTMLDivElement); + + // application/json + const jsonUrl = `${apiPrefix}/json`; + const res4 = await (await fetch(jsonUrl)).clone().json(); + expect(res4).toEqual({ + name: 'PageSpy', + }); + }); + + it('Big response entity will not be converted to base64 by PageSpy', async () => { + const np = new NetworkPlugin(); + np.onCreated(); + const { fetchProxy } = np; + expect(computeRequestMapInfo(fetchProxy).size).toBe(0); + + const bigFileUrl = `${apiPrefix}/big-file`; + await fetch(bigFileUrl); + await sleep(); + + const { freezedRequests, size } = computeRequestMapInfo(fetchProxy); + expect(size).toBe(1); + const current = Object.values(freezedRequests); + expect(current[0]?.response).toBe('[object Blob]'); + expect(current[0]?.responseReason).toBe(Reason.EXCEED_SIZE); + }); + + it('The SDK record the request information', () => { + const np = new NetworkPlugin(); + np.onCreated(); + const { fetchProxy } = np; + expect(fetchProxy).not.toBe(null); + expect(computeRequestMapInfo(fetchProxy).size).toBe(0); + + const count = 5; + Array.from({ length: count }).forEach((_, index) => { + fetch(`${apiPrefix}/posts/${index}`); + }); + expect(computeRequestMapInfo(fetchProxy).size).toBe(count); + }); + + it('The cached request items will be freed when no longer needed', async () => { + const np = new NetworkPlugin(); + np.onCreated(); + const { fetchProxy } = np; + expect(fetchProxy).not.toBe(null); + expect(computeRequestMapInfo(fetchProxy).size).toBe(0); + + const res = await fetch(`${apiPrefix}/json`); + expect(computeRequestMapInfo(fetchProxy).size).toBe(1); + + /** + * The `whatwg-fetch` relies on the setTimeout, the value wouldn't be resolved + * if we use `jest.useFakeTimers()`. So here we use the real timer. + * + * See: {@link https://github.com/jestjs/jest/issues/11103} + */ + await sleep(3500); + + // The previous request item now be freed after 3s. + expect(computeRequestMapInfo(fetchProxy).size).toBe(0); + }); +}); diff --git a/tests/plugins/network/util.ts b/tests/plugins/network/util.ts new file mode 100644 index 00000000..8a1584ca --- /dev/null +++ b/tests/plugins/network/util.ts @@ -0,0 +1,38 @@ +import BeaconProxy from 'src/plugins/network/proxy/beacon-proxy'; +import FetchProxy from 'src/plugins/network/proxy/fetch-proxy'; +import RequestItem from 'src/plugins/network/proxy/request-item'; +import XhrProxy from 'src/plugins/network/proxy/xhr-proxy'; + +type RequestStore = Record; +interface RequestsInfo { + requests: RequestStore; + freezedRequests: RequestStore; + size: number; +} +export const computeRequestMapInfo = ( + proxy: XhrProxy | FetchProxy | BeaconProxy | null, +): RequestsInfo => { + if (!proxy) { + return { + requests: {}, + freezedRequests: {}, + size: 0, + }; + } + const requestMap = proxy.getRequestMap(); + + return { + /** + * The `requests` value is UNSTABLE!!! + * It maybe changed at any time. + * + * See: {@link NetworkProxyBase#deferDeleteRequest} + */ + requests: requestMap, + // Because of reqMap may be changed at any time, we create a copy of it. + // + // NOTICE: The value simply represents the reqMap entity when printed. + freezedRequests: JSON.parse(JSON.stringify(requestMap)), + size: Object.keys(requestMap).length, + }; +}; diff --git a/tests/plugins/network/xhr-proxy.test.ts b/tests/plugins/network/xhr-proxy.test.ts new file mode 100644 index 00000000..88c610c8 --- /dev/null +++ b/tests/plugins/network/xhr-proxy.test.ts @@ -0,0 +1,204 @@ +import NetworkPlugin from 'src/plugins/network'; +import startServer from '../../server/index'; +import data from '../../server/data.json'; +import { Reason } from 'src/plugins/network/proxy/common'; +import { computeRequestMapInfo } from './util'; + +const port = 6677; +const apiPrefix = `http://localhost:${port}`; +const stopServer = startServer(port); +afterAll(stopServer); + +const sleep = (t = 100) => new Promise((r) => setTimeout(r, t)); + +const spyOpen = jest.spyOn(XMLHttpRequest.prototype, 'open'); +const spySetHeader = jest.spyOn(XMLHttpRequest.prototype, 'setRequestHeader'); +const spySend = jest.spyOn(XMLHttpRequest.prototype, 'send'); + +const { + open: originOpen, + setRequestHeader: originSetRequestHeader, + send: originSend, +} = window.XMLHttpRequest.prototype; +afterEach(() => { + jest.restoreAllMocks(); + window.XMLHttpRequest.prototype.open = originOpen; + window.XMLHttpRequest.prototype.setRequestHeader = originSetRequestHeader; + window.XMLHttpRequest.prototype.send = originSend; +}); + +describe('XMLHttpRequest proxy', () => { + it('Do nothing if not exist window.XMLHttpRequest', () => { + const originXHR = window.XMLHttpRequest; + Object.defineProperty(window, 'XMLHttpRequest', { + value: undefined, + writable: true, + }); + new NetworkPlugin().onCreated(); + expect(window.XMLHttpRequest).toBe(undefined); + window.XMLHttpRequest = originXHR; + }); + it('Wrap the XMLHttpRequest prototype method', () => { + new NetworkPlugin().onCreated(); + + expect(XMLHttpRequest.prototype.open).not.toBe(spyOpen); + expect(XMLHttpRequest.prototype.setRequestHeader).not.toBe(spySetHeader); + expect(XMLHttpRequest.prototype.send).not.toBe(spySend); + }); + + it("The origin's method will be called and get the response", (done) => { + new NetworkPlugin().onCreated(); + + const api = `${apiPrefix}/posts`; + const xhr = new XMLHttpRequest(); + xhr.open('GET', api); + xhr.setRequestHeader('X-Name', 'PageSpy'); + xhr.send(); + + expect(spyOpen).toHaveBeenCalled(); + expect(spySetHeader).toHaveBeenCalled(); + expect(spySend).toHaveBeenCalled(); + xhr.onload = () => { + expect(JSON.parse(xhr.response)).toEqual(data); + done(); + }; + }); + + it('Request different type response', () => { + new NetworkPlugin().onCreated(); + + const genPromise = (xhr: XMLHttpRequest) => { + return new Promise((resolve, reject) => { + xhr.addEventListener('readystatechange', () => { + if (xhr.readyState === 4) { + if (xhr.status === 200) { + resolve(xhr); + } else { + reject(new Error('request failed')); + } + } + }); + }); + }; + + // plain text + const xhr1 = new XMLHttpRequest(); + const xhr1_ps = genPromise(xhr1); + xhr1.responseType = 'text'; + xhr1.open('GET', `${apiPrefix}/plain-text`); + xhr1.send(); + + // blob + const xhr2 = new XMLHttpRequest(); + const xhr2_ps = genPromise(xhr2); + xhr2.responseType = 'blob'; + xhr2.open('GET', `${apiPrefix}/blob`); + xhr2.send(); + + // // document + const xhr3 = new XMLHttpRequest(); + const xhr3_ps = genPromise(xhr3); + xhr3.responseType = 'document'; + xhr3.open('GET', `${apiPrefix}/html`); + xhr3.send(); + + // json + const xhr4 = new XMLHttpRequest(); + const xhr4_ps = genPromise(xhr4); + xhr4.responseType = 'json'; + xhr4.open('GET', `${apiPrefix}/json`); + xhr4.send(); + + // arraybuffer + const xhr5 = new XMLHttpRequest(); + const xhr5_ps = genPromise(xhr5); + xhr5.responseType = 'arraybuffer'; + xhr5.open('GET', `${apiPrefix}/blob`); + xhr5.send(); + + return Promise.all([xhr1_ps, xhr2_ps, xhr3_ps, xhr4_ps, xhr5_ps]) + .then(([ins1, ins2, ins3, ins4, ins5]) => { + expect(ins1.responseText).toEqual( + expect.stringContaining('Hello PageSpy'), + ); + expect(ins2.status).toBe(200); + const doc = new DOMParser().parseFromString( + ins3.responseText, + 'text/html', + ); + const title = doc.querySelector('#app'); + expect(title).toBeInstanceOf(HTMLDivElement); + expect(ins4).toEqual({ + name: 'PageSpy', + }); + expect(ins5.status).toBe(200); + }) + .catch((e) => { + console.log('XHR execute failed: ', e.message); + }); + }); + + it('Big response entity will not be converted to base64 by PageSpy', (done) => { + const np = new NetworkPlugin(); + np.onCreated(); + const { xhrProxy } = np; + expect(xhrProxy).not.toBe(null); + expect(computeRequestMapInfo(xhrProxy!).size).toBe(0); + + const bigFileUrl = `${apiPrefix}/big-file`; + const xhr = new XMLHttpRequest(); + xhr.open('GET', bigFileUrl); + xhr.responseType = 'blob'; + xhr.send(); + xhr.addEventListener('readystatechange', async () => { + if (xhr.readyState === 4) { + if (xhr.status === 200) { + await sleep(); + const { freezedRequests, size } = computeRequestMapInfo(xhrProxy!); + expect(size).toBe(1); + const current = Object.values(freezedRequests)[0]; + expect(current?.response).toBe('[object Blob]'); + expect(current?.responseReason).toBe(Reason.EXCEED_SIZE); + done(); + } + } + }); + }); + + it('The SDK record the request information', () => { + const np = new NetworkPlugin(); + np.onCreated(); + const { xhrProxy } = np; + expect(xhrProxy).not.toBe(null); + expect(computeRequestMapInfo(xhrProxy).size).toBe(0); + + const count = 5; + Array.from({ length: count }).forEach((_, index) => { + const xhr = new XMLHttpRequest(); + xhr.open('GET', `${apiPrefix}/posts/${index}`); + xhr.send(); + }); + expect(computeRequestMapInfo(xhrProxy).size).toBe(count); + }); + + it('The cached request items will be freed when no longer needed', () => { + const np = new NetworkPlugin(); + np.onCreated(); + const { xhrProxy } = np; + expect(xhrProxy).not.toBe(null); + expect(computeRequestMapInfo(xhrProxy).size).toBe(0); + + const xhr = new XMLHttpRequest(); + xhr.open('GET', `${apiPrefix}/posts`); + xhr.send(); + + expect(computeRequestMapInfo(xhrProxy).size).toBe(1); + xhr.addEventListener('readystatechange', async () => { + if (xhr.readyState === 4) { + await sleep(3500); + // The previous request item now be freed after 3s. + expect(computeRequestMapInfo(xhrProxy).size).toBe(0); + } + }); + }); +}); diff --git a/tests/plugins/page.test.ts b/tests/plugins/page.test.ts index e5796330..a48b54c5 100644 --- a/tests/plugins/page.test.ts +++ b/tests/plugins/page.test.ts @@ -1,7 +1,7 @@ import PagePlugin from 'src/plugins/page'; import { DEBUG_MESSAGE_TYPE } from 'src/utils/message'; import socket from 'src/utils/socket'; - +// @ts-ignore const trigger = jest.spyOn(PagePlugin, 'collectHtml'); describe('Page plugin', () => { @@ -12,6 +12,7 @@ describe('Page plugin', () => { window.dispatchEvent(new Event('load')); expect(trigger).toHaveBeenCalledTimes(1); + // @ts-ignore socket.dispatchEvent(DEBUG_MESSAGE_TYPE.REFRESH, { source: { type: DEBUG_MESSAGE_TYPE.REFRESH, diff --git a/tests/plugins/storage.test.ts b/tests/plugins/storage.test.ts index f63b8919..181f32ca 100644 --- a/tests/plugins/storage.test.ts +++ b/tests/plugins/storage.test.ts @@ -1,6 +1,7 @@ import { StoragePlugin } from 'src/plugins/storage'; const trigger = jest.fn(); +// @ts-ignore jest.spyOn(StoragePlugin, 'sendStorageItem').mockImplementation(trigger); describe('Storage plugin', () => { diff --git a/tests/server/data.json b/tests/server/data.json index df8e2342..3f720bda 100644 --- a/tests/server/data.json +++ b/tests/server/data.json @@ -58,545 +58,5 @@ "id": 10, "title": "optio molestias id quia eum", "body": "quo et expedita modi cum officia vel magni\ndoloribus qui repudiandae\nvero nisi sit\nquos veniam quod sed accusamus veritatis error" - }, - { - "userId": 2, - "id": 11, - "title": "et ea vero quia laudantium autem", - "body": "delectus reiciendis molestiae occaecati non minima eveniet qui voluptatibus\naccusamus in eum beatae sit\nvel qui neque voluptates ut commodi qui incidunt\nut animi commodi" - }, - { - "userId": 2, - "id": 12, - "title": "in quibusdam tempore odit est dolorem", - "body": "itaque id aut magnam\npraesentium quia et ea odit et ea voluptas et\nsapiente quia nihil amet occaecati quia id voluptatem\nincidunt ea est distinctio odio" - }, - { - "userId": 2, - "id": 13, - "title": "dolorum ut in voluptas mollitia et saepe quo animi", - "body": "aut dicta possimus sint mollitia voluptas commodi quo doloremque\niste corrupti reiciendis voluptatem eius rerum\nsit cumque quod eligendi laborum minima\nperferendis recusandae assumenda consectetur porro architecto ipsum ipsam" - }, - { - "userId": 2, - "id": 14, - "title": "voluptatem eligendi optio", - "body": "fuga et accusamus dolorum perferendis illo voluptas\nnon doloremque neque facere\nad qui dolorum molestiae beatae\nsed aut voluptas totam sit illum" - }, - { - "userId": 2, - "id": 15, - "title": "eveniet quod temporibus", - "body": "reprehenderit quos placeat\nvelit minima officia dolores impedit repudiandae molestiae nam\nvoluptas recusandae quis delectus\nofficiis harum fugiat vitae" - }, - { - "userId": 2, - "id": 16, - "title": "sint suscipit perspiciatis velit dolorum rerum ipsa laboriosam odio", - "body": "suscipit nam nisi quo aperiam aut\nasperiores eos fugit maiores voluptatibus quia\nvoluptatem quis ullam qui in alias quia est\nconsequatur magni mollitia accusamus ea nisi voluptate dicta" - }, - { - "userId": 2, - "id": 17, - "title": "fugit voluptas sed molestias voluptatem provident", - "body": "eos voluptas et aut odit natus earum\naspernatur fuga molestiae ullam\ndeserunt ratione qui eos\nqui nihil ratione nemo velit ut aut id quo" - }, - { - "userId": 2, - "id": 18, - "title": "voluptate et itaque vero tempora molestiae", - "body": "eveniet quo quis\nlaborum totam consequatur non dolor\nut et est repudiandae\nest voluptatem vel debitis et magnam" - }, - { - "userId": 2, - "id": 19, - "title": "adipisci placeat illum aut reiciendis qui", - "body": "illum quis cupiditate provident sit magnam\nea sed aut omnis\nveniam maiores ullam consequatur atque\nadipisci quo iste expedita sit quos voluptas" - }, - { - "userId": 2, - "id": 20, - "title": "doloribus ad provident suscipit at", - "body": "qui consequuntur ducimus possimus quisquam amet similique\nsuscipit porro ipsam amet\neos veritatis officiis exercitationem vel fugit aut necessitatibus totam\nomnis rerum consequatur expedita quidem cumque explicabo" - }, - { - "userId": 3, - "id": 21, - "title": "asperiores ea ipsam voluptatibus modi minima quia sint", - "body": "repellat aliquid praesentium dolorem quo\nsed totam minus non itaque\nnihil labore molestiae sunt dolor eveniet hic recusandae veniam\ntempora et tenetur expedita sunt" - }, - { - "userId": 3, - "id": 22, - "title": "dolor sint quo a velit explicabo quia nam", - "body": "eos qui et ipsum ipsam suscipit aut\nsed omnis non odio\nexpedita earum mollitia molestiae aut atque rem suscipit\nnam impedit esse" - }, - { - "userId": 3, - "id": 23, - "title": "maxime id vitae nihil numquam", - "body": "veritatis unde neque eligendi\nquae quod architecto quo neque vitae\nest illo sit tempora doloremque fugit quod\net et vel beatae sequi ullam sed tenetur perspiciatis" - }, - { - "userId": 3, - "id": 24, - "title": "autem hic labore sunt dolores incidunt", - "body": "enim et ex nulla\nomnis voluptas quia qui\nvoluptatem consequatur numquam aliquam sunt\ntotam recusandae id dignissimos aut sed asperiores deserunt" - }, - { - "userId": 3, - "id": 25, - "title": "rem alias distinctio quo quis", - "body": "ullam consequatur ut\nomnis quis sit vel consequuntur\nipsa eligendi ipsum molestiae et omnis error nostrum\nmolestiae illo tempore quia et distinctio" - }, - { - "userId": 3, - "id": 26, - "title": "est et quae odit qui non", - "body": "similique esse doloribus nihil accusamus\nomnis dolorem fuga consequuntur reprehenderit fugit recusandae temporibus\nperspiciatis cum ut laudantium\nomnis aut molestiae vel vero" - }, - { - "userId": 3, - "id": 27, - "title": "quasi id et eos tenetur aut quo autem", - "body": "eum sed dolores ipsam sint possimus debitis occaecati\ndebitis qui qui et\nut placeat enim earum aut odit facilis\nconsequatur suscipit necessitatibus rerum sed inventore temporibus consequatur" - }, - { - "userId": 3, - "id": 28, - "title": "delectus ullam et corporis nulla voluptas sequi", - "body": "non et quaerat ex quae ad maiores\nmaiores recusandae totam aut blanditiis mollitia quas illo\nut voluptatibus voluptatem\nsimilique nostrum eum" - }, - { - "userId": 3, - "id": 29, - "title": "iusto eius quod necessitatibus culpa ea", - "body": "odit magnam ut saepe sed non qui\ntempora atque nihil\naccusamus illum doloribus illo dolor\neligendi repudiandae odit magni similique sed cum maiores" - }, - { - "userId": 3, - "id": 30, - "title": "a quo magni similique perferendis", - "body": "alias dolor cumque\nimpedit blanditiis non eveniet odio maxime\nblanditiis amet eius quis tempora quia autem rem\na provident perspiciatis quia" - }, - { - "userId": 4, - "id": 31, - "title": "ullam ut quidem id aut vel consequuntur", - "body": "debitis eius sed quibusdam non quis consectetur vitae\nimpedit ut qui consequatur sed aut in\nquidem sit nostrum et maiores adipisci atque\nquaerat voluptatem adipisci repudiandae" - }, - { - "userId": 4, - "id": 32, - "title": "doloremque illum aliquid sunt", - "body": "deserunt eos nobis asperiores et hic\nest debitis repellat molestiae optio\nnihil ratione ut eos beatae quibusdam distinctio maiores\nearum voluptates et aut adipisci ea maiores voluptas maxime" - }, - { - "userId": 4, - "id": 33, - "title": "qui explicabo molestiae dolorem", - "body": "rerum ut et numquam laborum odit est sit\nid qui sint in\nquasi tenetur tempore aperiam et quaerat qui in\nrerum officiis sequi cumque quod" - }, - { - "userId": 4, - "id": 34, - "title": "magnam ut rerum iure", - "body": "ea velit perferendis earum ut voluptatem voluptate itaque iusto\ntotam pariatur in\nnemo voluptatem voluptatem autem magni tempora minima in\nest distinctio qui assumenda accusamus dignissimos officia nesciunt nobis" - }, - { - "userId": 4, - "id": 35, - "title": "id nihil consequatur molestias animi provident", - "body": "nisi error delectus possimus ut eligendi vitae\nplaceat eos harum cupiditate facilis reprehenderit voluptatem beatae\nmodi ducimus quo illum voluptas eligendi\net nobis quia fugit" - }, - { - "userId": 4, - "id": 36, - "title": "fuga nam accusamus voluptas reiciendis itaque", - "body": "ad mollitia et omnis minus architecto odit\nvoluptas doloremque maxime aut non ipsa qui alias veniam\nblanditiis culpa aut quia nihil cumque facere et occaecati\nqui aspernatur quia eaque ut aperiam inventore" - }, - { - "userId": 4, - "id": 37, - "title": "provident vel ut sit ratione est", - "body": "debitis et eaque non officia sed nesciunt pariatur vel\nvoluptatem iste vero et ea\nnumquam aut expedita ipsum nulla in\nvoluptates omnis consequatur aut enim officiis in quam qui" - }, - { - "userId": 4, - "id": 38, - "title": "explicabo et eos deleniti nostrum ab id repellendus", - "body": "animi esse sit aut sit nesciunt assumenda eum voluptas\nquia voluptatibus provident quia necessitatibus ea\nrerum repudiandae quia voluptatem delectus fugit aut id quia\nratione optio eos iusto veniam iure" - }, - { - "userId": 4, - "id": 39, - "title": "eos dolorem iste accusantium est eaque quam", - "body": "corporis rerum ducimus vel eum accusantium\nmaxime aspernatur a porro possimus iste omnis\nest in deleniti asperiores fuga aut\nvoluptas sapiente vel dolore minus voluptatem incidunt ex" - }, - { - "userId": 4, - "id": 40, - "title": "enim quo cumque", - "body": "ut voluptatum aliquid illo tenetur nemo sequi quo facilis\nipsum rem optio mollitia quas\nvoluptatem eum voluptas qui\nunde omnis voluptatem iure quasi maxime voluptas nam" - }, - { - "userId": 5, - "id": 41, - "title": "non est facere", - "body": "molestias id nostrum\nexcepturi molestiae dolore omnis repellendus quaerat saepe\nconsectetur iste quaerat tenetur asperiores accusamus ex ut\nnam quidem est ducimus sunt debitis saepe" - }, - { - "userId": 5, - "id": 42, - "title": "commodi ullam sint et excepturi error explicabo praesentium voluptas", - "body": "odio fugit voluptatum ducimus earum autem est incidunt voluptatem\nodit reiciendis aliquam sunt sequi nulla dolorem\nnon facere repellendus voluptates quia\nratione harum vitae ut" - }, - { - "userId": 5, - "id": 43, - "title": "eligendi iste nostrum consequuntur adipisci praesentium sit beatae perferendis", - "body": "similique fugit est\nillum et dolorum harum et voluptate eaque quidem\nexercitationem quos nam commodi possimus cum odio nihil nulla\ndolorum exercitationem magnam ex et a et distinctio debitis" - }, - { - "userId": 5, - "id": 44, - "title": "optio dolor molestias sit", - "body": "temporibus est consectetur dolore\net libero debitis vel velit laboriosam quia\nipsum quibusdam qui itaque fuga rem aut\nea et iure quam sed maxime ut distinctio quae" - }, - { - "userId": 5, - "id": 45, - "title": "ut numquam possimus omnis eius suscipit laudantium iure", - "body": "est natus reiciendis nihil possimus aut provident\nex et dolor\nrepellat pariatur est\nnobis rerum repellendus dolorem autem" - }, - { - "userId": 5, - "id": 46, - "title": "aut quo modi neque nostrum ducimus", - "body": "voluptatem quisquam iste\nvoluptatibus natus officiis facilis dolorem\nquis quas ipsam\nvel et voluptatum in aliquid" - }, - { - "userId": 5, - "id": 47, - "title": "quibusdam cumque rem aut deserunt", - "body": "voluptatem assumenda ut qui ut cupiditate aut impedit veniam\noccaecati nemo illum voluptatem laudantium\nmolestiae beatae rerum ea iure soluta nostrum\neligendi et voluptate" - }, - { - "userId": 5, - "id": 48, - "title": "ut voluptatem illum ea doloribus itaque eos", - "body": "voluptates quo voluptatem facilis iure occaecati\nvel assumenda rerum officia et\nillum perspiciatis ab deleniti\nlaudantium repellat ad ut et autem reprehenderit" - }, - { - "userId": 5, - "id": 49, - "title": "laborum non sunt aut ut assumenda perspiciatis voluptas", - "body": "inventore ab sint\nnatus fugit id nulla sequi architecto nihil quaerat\neos tenetur in in eum veritatis non\nquibusdam officiis aspernatur cumque aut commodi aut" - }, - { - "userId": 5, - "id": 50, - "title": "repellendus qui recusandae incidunt voluptates tenetur qui omnis exercitationem", - "body": "error suscipit maxime adipisci consequuntur recusandae\nvoluptas eligendi et est et voluptates\nquia distinctio ab amet quaerat molestiae et vitae\nadipisci impedit sequi nesciunt quis consectetur" - }, - { - "userId": 6, - "id": 51, - "title": "soluta aliquam aperiam consequatur illo quis voluptas", - "body": "sunt dolores aut doloribus\ndolore doloribus voluptates tempora et\ndoloremque et quo\ncum asperiores sit consectetur dolorem" - }, - { - "userId": 6, - "id": 52, - "title": "qui enim et consequuntur quia animi quis voluptate quibusdam", - "body": "iusto est quibusdam fuga quas quaerat molestias\na enim ut sit accusamus enim\ntemporibus iusto accusantium provident architecto\nsoluta esse reprehenderit qui laborum" - }, - { - "userId": 6, - "id": 53, - "title": "ut quo aut ducimus alias", - "body": "minima harum praesentium eum rerum illo dolore\nquasi exercitationem rerum nam\nporro quis neque quo\nconsequatur minus dolor quidem veritatis sunt non explicabo similique" - }, - { - "userId": 6, - "id": 54, - "title": "sit asperiores ipsam eveniet odio non quia", - "body": "totam corporis dignissimos\nvitae dolorem ut occaecati accusamus\nex velit deserunt\net exercitationem vero incidunt corrupti mollitia" - }, - { - "userId": 6, - "id": 55, - "title": "sit vel voluptatem et non libero", - "body": "debitis excepturi ea perferendis harum libero optio\neos accusamus cum fuga ut sapiente repudiandae\net ut incidunt omnis molestiae\nnihil ut eum odit" - }, - { - "userId": 6, - "id": 56, - "title": "qui et at rerum necessitatibus", - "body": "aut est omnis dolores\nneque rerum quod ea rerum velit pariatur beatae excepturi\net provident voluptas corrupti\ncorporis harum reprehenderit dolores eligendi" - }, - { - "userId": 6, - "id": 57, - "title": "sed ab est est", - "body": "at pariatur consequuntur earum quidem\nquo est laudantium soluta voluptatem\nqui ullam et est\net cum voluptas voluptatum repellat est" - }, - { - "userId": 6, - "id": 58, - "title": "voluptatum itaque dolores nisi et quasi", - "body": "veniam voluptatum quae adipisci id\net id quia eos ad et dolorem\naliquam quo nisi sunt eos impedit error\nad similique veniam" - }, - { - "userId": 6, - "id": 59, - "title": "qui commodi dolor at maiores et quis id accusantium", - "body": "perspiciatis et quam ea autem temporibus non voluptatibus qui\nbeatae a earum officia nesciunt dolores suscipit voluptas et\nanimi doloribus cum rerum quas et magni\net hic ut ut commodi expedita sunt" - }, - { - "userId": 6, - "id": 60, - "title": "consequatur placeat omnis quisquam quia reprehenderit fugit veritatis facere", - "body": "asperiores sunt ab assumenda cumque modi velit\nqui esse omnis\nvoluptate et fuga perferendis voluptas\nillo ratione amet aut et omnis" - }, - { - "userId": 7, - "id": 61, - "title": "voluptatem doloribus consectetur est ut ducimus", - "body": "ab nemo optio odio\ndelectus tenetur corporis similique nobis repellendus rerum omnis facilis\nvero blanditiis debitis in nesciunt doloribus dicta dolores\nmagnam minus velit" - }, - { - "userId": 7, - "id": 62, - "title": "beatae enim quia vel", - "body": "enim aspernatur illo distinctio quae praesentium\nbeatae alias amet delectus qui voluptate distinctio\nodit sint accusantium autem omnis\nquo molestiae omnis ea eveniet optio" - }, - { - "userId": 7, - "id": 63, - "title": "voluptas blanditiis repellendus animi ducimus error sapiente et suscipit", - "body": "enim adipisci aspernatur nemo\nnumquam omnis facere dolorem dolor ex quis temporibus incidunt\nab delectus culpa quo reprehenderit blanditiis asperiores\naccusantium ut quam in voluptatibus voluptas ipsam dicta" - }, - { - "userId": 7, - "id": 64, - "title": "et fugit quas eum in in aperiam quod", - "body": "id velit blanditiis\neum ea voluptatem\nmolestiae sint occaecati est eos perspiciatis\nincidunt a error provident eaque aut aut qui" - }, - { - "userId": 7, - "id": 65, - "title": "consequatur id enim sunt et et", - "body": "voluptatibus ex esse\nsint explicabo est aliquid cumque adipisci fuga repellat labore\nmolestiae corrupti ex saepe at asperiores et perferendis\nnatus id esse incidunt pariatur" - }, - { - "userId": 7, - "id": 66, - "title": "repudiandae ea animi iusto", - "body": "officia veritatis tenetur vero qui itaque\nsint non ratione\nsed et ut asperiores iusto eos molestiae nostrum\nveritatis quibusdam et nemo iusto saepe" - }, - { - "userId": 7, - "id": 67, - "title": "aliquid eos sed fuga est maxime repellendus", - "body": "reprehenderit id nostrum\nvoluptas doloremque pariatur sint et accusantium quia quod aspernatur\net fugiat amet\nnon sapiente et consequatur necessitatibus molestiae" - }, - { - "userId": 7, - "id": 68, - "title": "odio quis facere architecto reiciendis optio", - "body": "magnam molestiae perferendis quisquam\nqui cum reiciendis\nquaerat animi amet hic inventore\nea quia deleniti quidem saepe porro velit" - }, - { - "userId": 7, - "id": 69, - "title": "fugiat quod pariatur odit minima", - "body": "officiis error culpa consequatur modi asperiores et\ndolorum assumenda voluptas et vel qui aut vel rerum\nvoluptatum quisquam perspiciatis quia rerum consequatur totam quas\nsequi commodi repudiandae asperiores et saepe a" - }, - { - "userId": 7, - "id": 70, - "title": "voluptatem laborum magni", - "body": "sunt repellendus quae\nest asperiores aut deleniti esse accusamus repellendus quia aut\nquia dolorem unde\neum tempora esse dolore" - }, - { - "userId": 8, - "id": 71, - "title": "et iusto veniam et illum aut fuga", - "body": "occaecati a doloribus\niste saepe consectetur placeat eum voluptate dolorem et\nqui quo quia voluptas\nrerum ut id enim velit est perferendis" - }, - { - "userId": 8, - "id": 72, - "title": "sint hic doloribus consequatur eos non id", - "body": "quam occaecati qui deleniti consectetur\nconsequatur aut facere quas exercitationem aliquam hic voluptas\nneque id sunt ut aut accusamus\nsunt consectetur expedita inventore velit" - }, - { - "userId": 8, - "id": 73, - "title": "consequuntur deleniti eos quia temporibus ab aliquid at", - "body": "voluptatem cumque tenetur consequatur expedita ipsum nemo quia explicabo\naut eum minima consequatur\ntempore cumque quae est et\net in consequuntur voluptatem voluptates aut" - }, - { - "userId": 8, - "id": 74, - "title": "enim unde ratione doloribus quas enim ut sit sapiente", - "body": "odit qui et et necessitatibus sint veniam\nmollitia amet doloremque molestiae commodi similique magnam et quam\nblanditiis est itaque\nquo et tenetur ratione occaecati molestiae tempora" - }, - { - "userId": 8, - "id": 75, - "title": "dignissimos eum dolor ut enim et delectus in", - "body": "commodi non non omnis et voluptas sit\nautem aut nobis magnam et sapiente voluptatem\net laborum repellat qui delectus facilis temporibus\nrerum amet et nemo voluptate expedita adipisci error dolorem" - }, - { - "userId": 8, - "id": 76, - "title": "doloremque officiis ad et non perferendis", - "body": "ut animi facere\ntotam iusto tempore\nmolestiae eum aut et dolorem aperiam\nquaerat recusandae totam odio" - }, - { - "userId": 8, - "id": 77, - "title": "necessitatibus quasi exercitationem odio", - "body": "modi ut in nulla repudiandae dolorum nostrum eos\naut consequatur omnis\nut incidunt est omnis iste et quam\nvoluptates sapiente aliquam asperiores nobis amet corrupti repudiandae provident" - }, - { - "userId": 8, - "id": 78, - "title": "quam voluptatibus rerum veritatis", - "body": "nobis facilis odit tempore cupiditate quia\nassumenda doloribus rerum qui ea\nillum et qui totam\naut veniam repellendus" - }, - { - "userId": 8, - "id": 79, - "title": "pariatur consequatur quia magnam autem omnis non amet", - "body": "libero accusantium et et facere incidunt sit dolorem\nnon excepturi qui quia sed laudantium\nquisquam molestiae ducimus est\nofficiis esse molestiae iste et quos" - }, - { - "userId": 8, - "id": 80, - "title": "labore in ex et explicabo corporis aut quas", - "body": "ex quod dolorem ea eum iure qui provident amet\nquia qui facere excepturi et repudiandae\nasperiores molestias provident\nminus incidunt vero fugit rerum sint sunt excepturi provident" - }, - { - "userId": 9, - "id": 81, - "title": "tempora rem veritatis voluptas quo dolores vero", - "body": "facere qui nesciunt est voluptatum voluptatem nisi\nsequi eligendi necessitatibus ea at rerum itaque\nharum non ratione velit laboriosam quis consequuntur\nex officiis minima doloremque voluptas ut aut" - }, - { - "userId": 9, - "id": 82, - "title": "laudantium voluptate suscipit sunt enim enim", - "body": "ut libero sit aut totam inventore sunt\nporro sint qui sunt molestiae\nconsequatur cupiditate qui iste ducimus adipisci\ndolor enim assumenda soluta laboriosam amet iste delectus hic" - }, - { - "userId": 9, - "id": 83, - "title": "odit et voluptates doloribus alias odio et", - "body": "est molestiae facilis quis tempora numquam nihil qui\nvoluptate sapiente consequatur est qui\nnecessitatibus autem aut ipsa aperiam modi dolore numquam\nreprehenderit eius rem quibusdam" - }, - { - "userId": 9, - "id": 84, - "title": "optio ipsam molestias necessitatibus occaecati facilis veritatis dolores aut", - "body": "sint molestiae magni a et quos\neaque et quasi\nut rerum debitis similique veniam\nrecusandae dignissimos dolor incidunt consequatur odio" - }, - { - "userId": 9, - "id": 85, - "title": "dolore veritatis porro provident adipisci blanditiis et sunt", - "body": "similique sed nisi voluptas iusto omnis\nmollitia et quo\nassumenda suscipit officia magnam sint sed tempora\nenim provident pariatur praesentium atque animi amet ratione" - }, - { - "userId": 9, - "id": 86, - "title": "placeat quia et porro iste", - "body": "quasi excepturi consequatur iste autem temporibus sed molestiae beatae\net quaerat et esse ut\nvoluptatem occaecati et vel explicabo autem\nasperiores pariatur deserunt optio" - }, - { - "userId": 9, - "id": 87, - "title": "nostrum quis quasi placeat", - "body": "eos et molestiae\nnesciunt ut a\ndolores perspiciatis repellendus repellat aliquid\nmagnam sint rem ipsum est" - }, - { - "userId": 9, - "id": 88, - "title": "sapiente omnis fugit eos", - "body": "consequatur omnis est praesentium\nducimus non iste\nneque hic deserunt\nvoluptatibus veniam cum et rerum sed" - }, - { - "userId": 9, - "id": 89, - "title": "sint soluta et vel magnam aut ut sed qui", - "body": "repellat aut aperiam totam temporibus autem et\narchitecto magnam ut\nconsequatur qui cupiditate rerum quia soluta dignissimos nihil iure\ntempore quas est" - }, - { - "userId": 9, - "id": 90, - "title": "ad iusto omnis odit dolor voluptatibus", - "body": "minus omnis soluta quia\nqui sed adipisci voluptates illum ipsam voluptatem\neligendi officia ut in\neos soluta similique molestias praesentium blanditiis" - }, - { - "userId": 10, - "id": 91, - "title": "aut amet sed", - "body": "libero voluptate eveniet aperiam sed\nsunt placeat suscipit molestias\nsimilique fugit nam natus\nexpedita consequatur consequatur dolores quia eos et placeat" - }, - { - "userId": 10, - "id": 92, - "title": "ratione ex tenetur perferendis", - "body": "aut et excepturi dicta laudantium sint rerum nihil\nlaudantium et at\na neque minima officia et similique libero et\ncommodi voluptate qui" - }, - { - "userId": 10, - "id": 93, - "title": "beatae soluta recusandae", - "body": "dolorem quibusdam ducimus consequuntur dicta aut quo laboriosam\nvoluptatem quis enim recusandae ut sed sunt\nnostrum est odit totam\nsit error sed sunt eveniet provident qui nulla" - }, - { - "userId": 10, - "id": 94, - "title": "qui qui voluptates illo iste minima", - "body": "aspernatur expedita soluta quo ab ut similique\nexpedita dolores amet\nsed temporibus distinctio magnam saepe deleniti\nomnis facilis nam ipsum natus sint similique omnis" - }, - { - "userId": 10, - "id": 95, - "title": "id minus libero illum nam ad officiis", - "body": "earum voluptatem facere provident blanditiis velit laboriosam\npariatur accusamus odio saepe\ncumque dolor qui a dicta ab doloribus consequatur omnis\ncorporis cupiditate eaque assumenda ad nesciunt" - }, - { - "userId": 10, - "id": 96, - "title": "quaerat velit veniam amet cupiditate aut numquam ut sequi", - "body": "in non odio excepturi sint eum\nlabore voluptates vitae quia qui et\ninventore itaque rerum\nveniam non exercitationem delectus aut" - }, - { - "userId": 10, - "id": 97, - "title": "quas fugiat ut perspiciatis vero provident", - "body": "eum non blanditiis soluta porro quibusdam voluptas\nvel voluptatem qui placeat dolores qui velit aut\nvel inventore aut cumque culpa explicabo aliquid at\nperspiciatis est et voluptatem dignissimos dolor itaque sit nam" - }, - { - "userId": 10, - "id": 98, - "title": "laboriosam dolor voluptates", - "body": "doloremque ex facilis sit sint culpa\nsoluta assumenda eligendi non ut eius\nsequi ducimus vel quasi\nveritatis est dolores" - }, - { - "userId": 10, - "id": 99, - "title": "temporibus sit alias delectus eligendi possimus magni", - "body": "quo deleniti praesentium dicta non quod\naut est molestias\nmolestias et officia quis nihil\nitaque dolorem quia" - }, - { - "userId": 10, - "id": 100, - "title": "at nam consequatur ea labore ea harum", - "body": "cupiditate quo est a modi nesciunt soluta\nipsa voluptas error itaque dicta in\nautem qui minus magnam et distinctio eum\naccusamus ratione error aut" } ] diff --git a/tests/server/favicon.png b/tests/server/favicon.png new file mode 100644 index 00000000..c1525b81 Binary files /dev/null and b/tests/server/favicon.png differ diff --git a/tests/server/index.js b/tests/server/index.js index e5117ac7..8f6d348a 100644 --- a/tests/server/index.js +++ b/tests/server/index.js @@ -2,14 +2,17 @@ const express = require('express'); const app = express(); const bodyParser = require('body-parser'); const data = require('./data.json'); +const { Blob } = require('buffer'); +const fs = require('fs'); +const path = require('path'); app.use(bodyParser.json()); -app.use(bodyParser.urlencoded({ extend: true })); +app.use(bodyParser.urlencoded({ extended: true })); app.use((req, res, next) => { res.header({ 'Access-Control-Allow-Origin': 'http://localhost', + 'Access-Control-Allow-Headers': '*', 'Access-Control-Allow-Credentials': true, - 'Content-Type': 'application/json', }); next(); }); @@ -25,16 +28,70 @@ app.get('/posts/:id', (_, res) => { res.send(data[0]); }); -const startServer = () => { - const server = app.listen(6677, () => { - console.log('Test server is RUNNING at http://localhost:6677'); +app.get('/plain-text', (_, res) => { + res.header({ + 'Content-Type': 'text/plain', + }); + res.send('Hello PageSpy'); +}); + +app.get('/json', (_, res) => { + res.json({ + name: 'PageSpy', + }); +}); + +app.get('/html', (_, res) => { + res.header({ + 'Content-Type': 'text/html', + }); + res.send('

Hello PageSpy

'); +}); + +app.get('/blob', (_, res) => { + const pngFile = path.resolve(__dirname, './favicon.png'); + fs.readFile(pngFile, (err, data) => { + if (err) { + console.log(err); + res.status(500).send('Internal server error'); + return; + } + + res.header({ + 'Content-Type': 'image/png', + }); + res.send(data); + }); +}); + +app.get('/big-file', (_, res) => { + const pngFile = path.resolve(__dirname, './unsplash.jpeg'); + fs.readFile(pngFile, (err, data) => { + if (err) { + console.log(err); + res.status(500).send('Internal server error'); + return; + } + + res.header({ + 'Content-Type': 'image/jpg', + }); + res.send(data); + }); +}); + +const startServer = (port = 6677) => { + const server = app.listen(port, () => { + console.log(`Test server is RUNNING at http://localhost:${port}`); }); server.unref(); return () => { - server.close((err) => { - console.log('Http server closed.'); - }); + server.close((err) => {}); }; }; module.exports = startServer; + +// const server = app.listen(6677, () => { +// console.log('Test server is RUNNING at http://localhost:6677'); +// }); diff --git a/tests/server/unsplash.jpeg b/tests/server/unsplash.jpeg new file mode 100644 index 00000000..63928288 Binary files /dev/null and b/tests/server/unsplash.jpeg differ diff --git a/tests/utils/atom.test.ts b/tests/utils/atom.test.ts index c2bcf5ba..1a30e366 100644 --- a/tests/utils/atom.test.ts +++ b/tests/utils/atom.test.ts @@ -2,23 +2,23 @@ import atom from 'src/utils/atom'; // jest.mock('src/utils/atom.ts'); beforeEach(() => { - atom.store = {}; - atom.instanceStore = {}; + atom.resetStore(); + atom.resetInstanceStore(); }); describe('Atom', () => { it('Data would be cacehed after call atom.add([data])', () => { const data = {}; atom.add(data); - expect(Object.keys(atom.store).length).toBe(1); - expect(Object.keys(atom.instanceStore).length).toBe(1); - expect(Object.values(atom.store)[0]).toBe(data); + expect(Object.keys(atom.getStore()).length).toBe(1); + expect(Object.keys(atom.getInstanceStore()).length).toBe(1); + expect(Object.values(atom.getStore())[0]).toBe(data); }); it('Self-reference data is ok', () => { atom.add(window); - expect(Object.keys(atom.store).length).toBe(1); - expect(Object.keys(atom.instanceStore).length).toBe(1); - expect(Object.values(atom.store)[0]).toBe(window); + expect(Object.keys(atom.getStore()).length).toBe(1); + expect(Object.keys(atom.getInstanceStore()).length).toBe(1); + expect(Object.values(atom.getStore())[0]).toBe(window); }); }); @@ -39,7 +39,7 @@ describe('Atom.get', () => { arrowFunc: () => {}, }; atom.add(data); - const atomId = Object.keys(atom.store)[0]; + const atomId = Object.keys(atom.getStore())[0]; const atomNode = atom.get(atomId); Object.keys(data).forEach((key) => { expect(atomNode).toHaveProperty(key); @@ -51,7 +51,7 @@ describe('Atom.addExtraProperty', () => { it('[[PrimitiveValue]] prop added to `new String` data', () => { const data = new String('Hello, PageSpy'); atom.add(data); - const atomId = Object.keys(atom.store)[0]; + const atomId = Object.keys(atom.getStore())[0]; const atomNode = atom.get(atomId); const atomNodeValue = atomNode!['[[PrimitiveValue]]']; expect(atomNode).not.toBeNull(); @@ -61,7 +61,7 @@ describe('Atom.addExtraProperty', () => { it('[[PrimitiveValue]] prop added to `new Number` data', () => { const data = new Number(520); atom.add(data); - const atomId = Object.keys(atom.store)[0]; + const atomId = Object.keys(atom.getStore())[0]; const atomNode = atom.get(atomId); const atomNodeValue = atomNode!['[[PrimitiveValue]]']; expect(atomNode).not.toBeNull(); @@ -71,7 +71,7 @@ describe('Atom.addExtraProperty', () => { it('[[PrimitiveValue]] prop added to `new Boolean` data', () => { const data = new Boolean(true); atom.add(data); - const atomId = Object.keys(atom.store)[0]; + const atomId = Object.keys(atom.getStore())[0]; const atomNode = atom.get(atomId); const atomNodeValue = atomNode!['[[PrimitiveValue]]']; expect(atomNode).not.toBeNull(); @@ -81,7 +81,7 @@ describe('Atom.addExtraProperty', () => { it('[[Entries]] prop added to `new Set` data', () => { const data = new Set([1, 2, 3, 1]); atom.add(data); - const atomId = Object.keys(atom.store)[0]; + const atomId = Object.keys(atom.getStore())[0]; const atomNode = atom.get(atomId); const atomNodeValue = atomNode!['[[Entries]]']; expect(atomNode).not.toBeNull(); @@ -90,7 +90,7 @@ describe('Atom.addExtraProperty', () => { it('[[Entries]] prop added to `new Map` data', () => { const data = new Map([[window, document]]); atom.add(data); - const atomId = Object.keys(atom.store)[0]; + const atomId = Object.keys(atom.getStore())[0]; const atomNode = atom.get(atomId); const atomNodeValue = atomNode!['[[Entries]]']; expect(atomNode).not.toBeNull(); @@ -99,7 +99,7 @@ describe('Atom.addExtraProperty', () => { it('[[Prototype]] prop added to prototype data except `Object.prototype` type', () => { const data = new Number(123); atom.add(data); - const atomId = Object.keys(atom.store)[0]; + const atomId = Object.keys(atom.getStore())[0]; const atomNode = atom.get(atomId); const atomNodeValue = atomNode!['[[Prototype]]']; expect(atomNode).not.toBeNull(); @@ -108,7 +108,7 @@ describe('Atom.addExtraProperty', () => { it('___proto___ prop added to `Object.prototype` data', () => { const data = Object.prototype; atom.add(data); - const atomId = Object.keys(atom.store)[0]; + const atomId = Object.keys(atom.getStore())[0]; const atomNode = atom.get(atomId); const atomNodeValue = atomNode!['___proto___']; expect(atomNode).not.toBeNull(); diff --git a/tests/utils/blob.test.ts b/tests/utils/blob.test.ts index 5e82db6c..067b6b35 100644 --- a/tests/utils/blob.test.ts +++ b/tests/utils/blob.test.ts @@ -1,15 +1,9 @@ -import { blob2base64 } from 'src/utils/blob'; +import { blob2base64Async } from 'src/utils/blob'; -describe('Blob utils', () => { - it('Convert blob to base64', (done) => { - const content = 'Hello PageSpy'; - const blob = new Blob([content], { type: 'text/plain' }); - const cb = jest.fn((data: any) => data); - expect.assertions(1); - blob2base64(blob, (data) => { - const code = data.split(',')[1]; - expect(atob(code)).toBe(content); - done(); - }); +describe('blob2base64Async', () => { + test('should convert a Blob to base64', async () => { + const mockBlob = new Blob(['Hello, world!'], { type: 'text/plain' }); + const result = await blob2base64Async(mockBlob); + expect(result).toEqual('data:text/plain;base64,SGVsbG8sIHdvcmxkIQ=='); }); }); diff --git a/tests/utils/index.test.ts b/tests/utils/index.test.ts index 24abd2ac..7d64ccc0 100644 --- a/tests/utils/index.test.ts +++ b/tests/utils/index.test.ts @@ -1,33 +1,27 @@ +import { BINARY_FILE_VARIANT } from 'src/plugins/network/proxy/common'; import { makePrimitiveValue, getValueType } from 'src/utils'; describe('makePrimitiveValue: convert data to showable string', () => { - it('Primitive undefined is ok', () => { - expect(makePrimitiveValue(undefined).value).toBe('undefined'); + it('✅ Primitive is ok', () => { + [ + { received: undefined, expected: 'undefined' }, + { received: 0, expected: 0 }, + { received: Infinity, expected: 'Infinity' }, + { received: -Infinity, expected: '-Infinity' }, + { received: NaN, expected: 'NaN' }, + { received: 123n, expected: '123n' }, + { received: Symbol('foo'), expected: 'Symbol(foo)' }, + ].forEach(({ received, expected }) => { + expect(makePrimitiveValue(received).value).toBe(expected); + }); }); - it('Primitive number is ok', () => { - expect(makePrimitiveValue(0).value).toBe(0); - expect(makePrimitiveValue(1).value).toBe(1); - expect(makePrimitiveValue(-1).value).toBe(-1); - expect(makePrimitiveValue(Infinity).value).toBe('Infinity'); - expect(makePrimitiveValue(-Infinity).value).toBe('-Infinity'); - expect(makePrimitiveValue(NaN).value).toBe('NaN'); - expect(makePrimitiveValue(123n).value).toBe('123n'); + it('✅ Function / Error is ok', () => { + [Math.pow, () => {}, new Error()].forEach((item) => { + expect(makePrimitiveValue(item).ok).toBe(true); + }); }); - it('Function is ok', () => { - expect(makePrimitiveValue(Math.pow).ok).toBe(true); - expect(makePrimitiveValue(function pow() {}).ok).toBe(true); - expect(makePrimitiveValue(() => {}).ok).toBe(true); - }); - it('Symbol is ok', () => { - expect(makePrimitiveValue(Symbol('foo')).ok).toBe(true); - }); - it('Error is ok', () => { - expect(makePrimitiveValue(new Error()).ok).toBe(true); - expect(makePrimitiveValue(new TypeError()).ok).toBe(true); - expect(makePrimitiveValue(new SyntaxError()).ok).toBe(true); - expect(makePrimitiveValue(new ReferenceError()).ok).toBe(true); - }); - it('Reference type cannot be transformed', () => { + + it('❌ Reference type cannot be transformed', () => { expect(makePrimitiveValue(new Number(1)).ok).toBe(false); expect(makePrimitiveValue(new String('PageSpy')).ok).toBe(false); expect(makePrimitiveValue(new Boolean(true)).ok).toBe(false); @@ -45,9 +39,8 @@ describe('getValueType', () => { expect(getValueType('')).toBe('string'); expect(getValueType(new String())).toBe('object'); - expect(getValueType(123n)).toBe('bigint'); - expect(getValueType(BigInt(123))).toBe('bigint'); expect(getValueType(123)).toBe('number'); + expect(getValueType(123n)).toBe('bigint'); expect(getValueType(new Number(123))).toBe('object'); expect(getValueType(() => {})).toBe('function'); @@ -55,9 +48,7 @@ describe('getValueType', () => { expect(getValueType(new Function())).toBe('function'); expect(getValueType(new Error())).toBe('error'); - expect(getValueType({})).toBe('object'); expect(getValueType(new Object())).toBe('object'); expect(getValueType(Object.create(null))).toBe('object'); - expect(getValueType([])).toBe('object'); expect(getValueType(new Array())).toBe('object'); }); diff --git a/tests/utils/socket.test.ts b/tests/utils/socket.test.ts index 501e26f2..92da45a8 100644 --- a/tests/utils/socket.test.ts +++ b/tests/utils/socket.test.ts @@ -29,9 +29,10 @@ const fakeUrl = 'ws://localhost:1234'; describe('Socket store', () => { it('Close, Reconnect', async () => { + // @ts-ignore const reconnect = jest.spyOn(client, 'tryReconnect'); expect(client.connectionStatus).toBe(true); - client.socket?.close(); + client.getSocket()?.close(); await sleep(); expect(reconnect).toHaveBeenCalledTimes(1); @@ -39,11 +40,14 @@ describe('Socket store', () => { }); it('Connect failed if reconnect over 3 times', async () => { + // @ts-ignore expect(client.reconnectTimes).toBe(3); server.close(); await sleep(300); + // @ts-ignore expect(client.reconnectTimes).toBe(0); + // @ts-ignore expect(client.reconnectable).toBe(false); }); @@ -79,6 +83,7 @@ describe('Socket store', () => { }; server.send(connectMsg); await sleep(); + // @ts-ignore expect(client.socketConnection).toEqual(connectMsg.content.selfConnection); // `Send` type message diff --git a/types/lib/network.d.ts b/types/lib/network.d.ts index 90873098..b1586e74 100644 --- a/types/lib/network.d.ts +++ b/types/lib/network.d.ts @@ -4,18 +4,42 @@ export interface RequestInfo { method: string; url: string; requestType: 'xhr' | 'fetch' | 'ping'; - requestHeader: HeadersInit | null; + /** + * `Content-Type` is a special value in requestHeader. + * The property isn't required for `GET` request, + * but there are some cases when the request method is`POST` + * and the property value will depend on body's type: + * 1. : the property is unnecessary + * 2. FormData: "multipart/form-data" + * 3. URLSearchParams: "application/x-www-form-urlencoded;charset=UTF-8" + * 4. Blob: depended on the entity type, there are "text/plain", "image/png" etc. + * 5. String: "text/plain" + * 6. Document: "application/xml" + * 7. Others: "text/plain" + */ + requestHeader: [string, string][] | null; status: number | string; statusText: string; readyState: XMLHttpRequest['readyState']; response: any; responseReason: string | null; responseType: XMLHttpRequest['responseType']; - responseHeader: Record | null; + responseHeader: [string, string][] | null; startTime: number; endTime: number; costTime: number; - getData: Record | null; - postData: Record | string | null; + getData: [string, string][] | null; + /** + * @deprecated please using `requestPayload` + */ + postData: [string, string][] | string | null; + /** + * Base on the 'requestHeader' field mentioned above, FormData and USP + * are the only two types of request payload that can have the same key. + * SO, we store the postData with different structure: + * - FormData / USP: [string, string][] + * - Others: string. (Tips: the body maybe serialized json string, you can try to deserialize it as need) + */ + requestPayload: [string, string][] | string | null; withCredentials: boolean; }