From e873246daafa37587a4f9000f13cf92a3acbf6d3 Mon Sep 17 00:00:00 2001 From: waterplea Date: Tue, 16 Feb 2021 15:38:39 +0300 Subject: [PATCH] fix(WINDOW): provide typesafe mock for `WINDOW` --- README.md | 4 +- projects/universal/src/classes/blob-mock.ts | 5 + .../src/constants/universal-navigator.ts | 27 +-- ...ersal-tokens.ts => universal-providers.ts} | 2 + .../constants/universal-speech-synthesis.ts | 16 +- .../src/constants/universal-window.ts | 218 ++++++++++++++++++ projects/universal/src/public-api.ts | 4 +- projects/universal/src/utils/event-target.ts | 7 + projects/universal/src/utils/functions.ts | 8 +- .../universal/src/utils/provide-location.ts | 8 +- .../src/utils/tests/functions.spec.ts | 11 +- 11 files changed, 275 insertions(+), 35 deletions(-) create mode 100644 projects/universal/src/classes/blob-mock.ts rename projects/universal/src/constants/{universal-tokens.ts => universal-providers.ts} (92%) create mode 100644 projects/universal/src/constants/universal-window.ts create mode 100644 projects/universal/src/utils/event-target.ts diff --git a/README.md b/README.md index 45b1db4..5471284 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,8 @@ will provide the same functionality on the server side as you have in browser. I you will get type-safe mocks and you can at least be sure you will not have `cannot read propery of null` or `undefined is not a function` errors in SSR. +> **IMPORTANT:** This library relies on **_Node.js_ v10** and above on your server side + ## Mocks Add following line to your `server.ts` to mock native classes used in other @ng-web-apis packages: @@ -32,7 +34,7 @@ import '@ng-web-apis/universal/mocks'; ## Tokens -- `WINDOW` — no need to provide fallback, Angular Universal handles it +- `WINDOW` — add `UNIVERSAL_WINDOW` to provide type-safe mock object, effectively mocking all `navigator` related entities - `NAVIGATOR` — add `UNIVERSAL_NAVIGATOR` to provide type-safe mock object, effectively mocking all `navigator` related entities - `NETWORK_INFORMATION` — no need to do anything - `USER_AGENT` — see _special cases_ below diff --git a/projects/universal/src/classes/blob-mock.ts b/projects/universal/src/classes/blob-mock.ts new file mode 100644 index 0000000..b751f9f --- /dev/null +++ b/projects/universal/src/classes/blob-mock.ts @@ -0,0 +1,5 @@ +export class BlobMock implements Blob { + size = 0; + type = ''; + slice = () => this; +} diff --git a/projects/universal/src/constants/universal-navigator.ts b/projects/universal/src/constants/universal-navigator.ts index d880a38..370b88f 100644 --- a/projects/universal/src/constants/universal-navigator.ts +++ b/projects/universal/src/constants/universal-navigator.ts @@ -1,21 +1,16 @@ import {FactoryProvider, Optional} from '@angular/core'; import {NAVIGATOR} from '@ng-web-apis/common'; import {SSR_USER_AGENT} from '../tokens/ssr-user-agent'; +import {EVENT_TARGET} from '../utils/event-target'; import { alwaysFalse, alwaysRejected, alwaysZero, - empty, emptyArray, + emptyFunction, emptyObject, } from '../utils/functions'; -const EVENT_TARGET: EventTarget = { - addEventListener: empty, - dispatchEvent: alwaysFalse, - removeEventListener: empty, -}; - const PLUGIN = new (class extends Array implements Plugin { readonly description = ''; readonly filename = ''; @@ -71,10 +66,10 @@ export function navigatorFactory(userAgent: string | null): NavigatorLike { confirmSiteSpecificTrackingException: alwaysFalse, confirmWebWideTrackingException: alwaysFalse, - removeSiteSpecificTrackingException: empty, - removeWebWideTrackingException: empty, - storeSiteSpecificTrackingException: empty, - storeWebWideTrackingException: empty, + removeSiteSpecificTrackingException: emptyFunction, + removeWebWideTrackingException: emptyFunction, + storeSiteSpecificTrackingException: emptyFunction, + storeWebWideTrackingException: emptyFunction, msSaveBlob: alwaysFalse, msSaveOrOpenBlob: alwaysFalse, @@ -108,8 +103,8 @@ export function navigatorFactory(userAgent: string | null): NavigatorLike { doNotTrack: null, gamepadInputEmulation: 'keyboard', geolocation: { - clearWatch: empty, - getCurrentPosition: empty, + clearWatch: emptyFunction, + getCurrentPosition: emptyFunction, watchPosition: alwaysZero, }, maxTouchPoints: 0, @@ -139,14 +134,14 @@ export function navigatorFactory(userAgent: string | null): NavigatorLike { getRegistration: alwaysRejected, getRegistrations: alwaysRejected, register: alwaysRejected, - startMessages: empty, + startMessages: emptyFunction, }, webdriver: false, getGamepads: emptyArray, - getUserMedia: empty, + getUserMedia: emptyFunction, getVRDisplays: alwaysRejected, javaEnabled: alwaysFalse, - msLaunchUri: empty, + msLaunchUri: emptyFunction, requestMediaKeySystemAccess: alwaysRejected, vibrate: alwaysFalse, }; diff --git a/projects/universal/src/constants/universal-tokens.ts b/projects/universal/src/constants/universal-providers.ts similarity index 92% rename from projects/universal/src/constants/universal-tokens.ts rename to projects/universal/src/constants/universal-providers.ts index c325922..1efcc27 100644 --- a/projects/universal/src/constants/universal-tokens.ts +++ b/projects/universal/src/constants/universal-providers.ts @@ -7,6 +7,7 @@ import {UNIVERSAL_PERFORMANCE} from './universal-performance'; import {UNIVERSAL_SESSION_STORAGE} from './universal-session-storage'; import {UNIVERSAL_SPEECH_SYNTHESIS} from './universal-speech-synthesis'; import {UNIVERSAL_USER_AGENT} from './universal-user-agent'; +import {UNIVERSAL_WINDOW} from './universal-window'; export const UNIVERSAL_PROVIDERS: Provider[] = [ UNIVERSAL_ANIMATION_FRAME, @@ -17,6 +18,7 @@ export const UNIVERSAL_PROVIDERS: Provider[] = [ UNIVERSAL_PERFORMANCE, UNIVERSAL_SPEECH_SYNTHESIS, UNIVERSAL_USER_AGENT, + UNIVERSAL_WINDOW, ]; /** @deprecated use {@link UNIVERSAL_PROVIDERS} */ diff --git a/projects/universal/src/constants/universal-speech-synthesis.ts b/projects/universal/src/constants/universal-speech-synthesis.ts index fbd3372..8d1f847 100644 --- a/projects/universal/src/constants/universal-speech-synthesis.ts +++ b/projects/universal/src/constants/universal-speech-synthesis.ts @@ -1,19 +1,19 @@ import {ValueProvider} from '@angular/core'; import {SPEECH_SYNTHESIS} from '@ng-web-apis/common'; -import {alwaysFalse, empty, emptyArray} from '../utils/functions'; +import {alwaysFalse, emptyArray, emptyFunction} from '../utils/functions'; const MOCK: SpeechSynthesis = { paused: false, pending: false, speaking: false, - onvoiceschanged: empty, - addEventListener: empty, - removeEventListener: empty, + onvoiceschanged: emptyFunction, + addEventListener: emptyFunction, + removeEventListener: emptyFunction, dispatchEvent: alwaysFalse, - cancel: empty, - pause: empty, - resume: empty, - speak: empty, + cancel: emptyFunction, + pause: emptyFunction, + resume: emptyFunction, + speak: emptyFunction, getVoices: emptyArray, }; diff --git a/projects/universal/src/constants/universal-window.ts b/projects/universal/src/constants/universal-window.ts new file mode 100644 index 0000000..386ba3d --- /dev/null +++ b/projects/universal/src/constants/universal-window.ts @@ -0,0 +1,218 @@ +import {DOCUMENT} from '@angular/common'; +import {FactoryProvider} from '@angular/core'; +import { + LOCAL_STORAGE, + LOCATION, + NAVIGATOR, + PERFORMANCE, + SESSION_STORAGE, + SPEECH_SYNTHESIS, + WINDOW, +} from '@ng-web-apis/common'; +import {BlobMock} from '../classes/blob-mock'; +import {EVENT_TARGET} from '../utils/event-target'; +import { + alwaysFalse, + alwaysNull, + alwaysRejected, + alwaysZero, + emptyFunction, + identity, +} from '../utils/functions'; + +const COMPUTED_STYLES: Partial = { + getPropertyPriority: () => '', + getPropertyValue: () => '', + item: () => '', + removeProperty: () => '', + setProperty: emptyFunction, +}; +const COMPUTED_STYLES_HANDLER: ProxyHandler = { + get: (obj, key: any) => (key in obj ? obj[key] : null), +}; +const COMPUTED_STYLES_PROXY = new Proxy( + COMPUTED_STYLES as any, + COMPUTED_STYLES_HANDLER, +); +const CSS_RULES = new (class extends Array implements CSSRuleList { + item = () => null; +})(); +const BAR_PROP: BarProp = { + visible: false, +}; +const DB_REQUEST: IDBOpenDBRequest = { + ...EVENT_TARGET, + onblocked: null, + onerror: null, + onsuccess: null, + onupgradeneeded: null, + error: null, + readyState: 'pending', + result: null as any, // Cannot be accessed for 'pending' state anyway + source: null as any, // null for open requests + transaction: null, +}; +const SELF = ['frames', 'parent', 'self', 'top', 'window']; +const WINDOW_HANDLER: ProxyHandler = { + get: (windowRef, key: keyof Window) => { + if (SELF.includes(key)) { + return windowRef; + } + + return key.startsWith('on') ? null : windowRef[key]; + }, +}; + +export function windowFactory( + document: Document, + navigator: Navigator, + localStorage: Storage, + location: Location, + performance: Performance, + sessionStorage: Storage, + speechSynthesis: SpeechSynthesis, +): Window { + const windowMock: Window = { + ...EVENT_TARGET, + document, + localStorage, + location, + navigator, + performance, + sessionStorage, + speechSynthesis, + URL, + URLSearchParams, + setTimeout, + setInterval, + clearTimeout, + clearInterval, + console, + Blob: BlobMock, + alert: emptyFunction, + clientInformation: navigator, + // TODO: Candidate for token + matchMedia: () => ({ + ...EVENT_TARGET, + matches: false, + media: '', + onchange: null, + addListener: emptyFunction, + removeListener: emptyFunction, + }), + // TODO: Candidate for token + caches: { + delete: () => Promise.resolve(false), + has: () => Promise.resolve(false), + keys: () => Promise.resolve([]), + match: alwaysRejected, + open: alwaysRejected, + }, + // TODO: Candidate for token + indexedDB: { + cmp: alwaysZero, + open: () => DB_REQUEST, + deleteDatabase: () => DB_REQUEST, + }, + customElements: { + define: emptyFunction, + get: emptyFunction, + upgrade: emptyFunction, + whenDefined: alwaysRejected, + }, + styleMedia: { + type: '', + matchMedium: alwaysFalse, + }, + crypto: { + subtle: undefined as any, // Insecure context + getRandomValues: identity, + }, + history: { + length: 0, + scrollRestoration: 'auto', + state: {}, + back: emptyFunction, + forward: emptyFunction, + go: emptyFunction, + pushState: emptyFunction, + replaceState: emptyFunction, + }, + closed: false, + defaultStatus: '', + devicePixelRatio: 1, + doNotTrack: '', + frameElement: null as any, // TODO: bug in TypeScript + innerHeight: 0, + innerWidth: 0, + isSecureContext: false, + length: 0, + name: '', + offscreenBuffering: false, + opener: {}, + origin: '', + orientation: '', + outerHeight: 0, + outerWidth: 0, + pageXOffset: 0, + pageYOffset: 0, + screenLeft: 0, + screenTop: 0, + screenX: 0, + screenY: 0, + scrollX: 0, + scrollY: 0, + status: '', + blur: emptyFunction, + cancelAnimationFrame: emptyFunction, + captureEvents: emptyFunction, + close: emptyFunction, + confirm: alwaysFalse, + departFocus: emptyFunction, + focus: emptyFunction, + moveBy: emptyFunction, + moveTo: emptyFunction, + open: alwaysNull, + postMessage: emptyFunction, + print: emptyFunction, + prompt: alwaysNull, + releaseEvents: emptyFunction, + requestAnimationFrame: alwaysZero, + resizeBy: emptyFunction, + resizeTo: emptyFunction, + scroll: emptyFunction, + scrollBy: emptyFunction, + scrollTo: emptyFunction, + stop: emptyFunction, + atob: identity, + btoa: identity, + fetch: alwaysRejected, + createImageBitmap: alwaysRejected, + queueMicrotask: emptyFunction, + locationbar: BAR_PROP, + menubar: BAR_PROP, + personalbar: BAR_PROP, + scrollbars: BAR_PROP, + statusbar: BAR_PROP, + toolbar: BAR_PROP, + getComputedStyle: () => COMPUTED_STYLES_PROXY, + getMatchedCSSRules: () => CSS_RULES, + getSelection: () => null as any, // TODO: old TypeScript issue + } as any; + + return new Proxy(windowMock, WINDOW_HANDLER); +} + +export const UNIVERSAL_WINDOW: FactoryProvider = { + provide: WINDOW, + deps: [ + DOCUMENT, + LOCAL_STORAGE, + LOCATION, + NAVIGATOR, + PERFORMANCE, + SESSION_STORAGE, + SPEECH_SYNTHESIS, + ], + useFactory: windowFactory, +}; diff --git a/projects/universal/src/public-api.ts b/projects/universal/src/public-api.ts index 6b22e2f..0f58929 100644 --- a/projects/universal/src/public-api.ts +++ b/projects/universal/src/public-api.ts @@ -9,8 +9,10 @@ export * from './constants/universal-navigator'; export * from './constants/universal-performance'; export * from './constants/universal-session-storage'; export * from './constants/universal-speech-synthesis'; -export * from './constants/universal-tokens'; export * from './constants/universal-user-agent'; +export * from './constants/universal-window'; + +export * from './constants/universal-providers'; // Utils export * from './utils/provide-location'; diff --git a/projects/universal/src/utils/event-target.ts b/projects/universal/src/utils/event-target.ts new file mode 100644 index 0000000..a94668e --- /dev/null +++ b/projects/universal/src/utils/event-target.ts @@ -0,0 +1,7 @@ +import {alwaysFalse, emptyFunction} from './functions'; + +export const EVENT_TARGET: EventTarget = { + addEventListener: emptyFunction, + dispatchEvent: alwaysFalse, + removeEventListener: emptyFunction, +}; diff --git a/projects/universal/src/utils/functions.ts b/projects/universal/src/utils/functions.ts index 617fcae..e9e6b19 100644 --- a/projects/universal/src/utils/functions.ts +++ b/projects/universal/src/utils/functions.ts @@ -2,7 +2,7 @@ export function identity(v: T): T { return v; } -export function empty() {} +export function emptyFunction() {} export function emptyArray(): any[] { return []; @@ -16,10 +16,14 @@ export function alwaysFalse(): boolean { return false; } +export function alwaysNull(): null { + return null; +} + export function alwaysZero(): number { return 0; } export function alwaysRejected(): Promise { - return Promise.reject().catch(empty); + return Promise.reject().catch(emptyFunction); } diff --git a/projects/universal/src/utils/provide-location.ts b/projects/universal/src/utils/provide-location.ts index 0cf67b4..e147925 100644 --- a/projects/universal/src/utils/provide-location.ts +++ b/projects/universal/src/utils/provide-location.ts @@ -1,15 +1,15 @@ import {ValueProvider} from '@angular/core'; import {IncomingMessage} from 'http'; import {SSR_LOCATION} from '../tokens/ssr-location'; -import {empty} from './functions'; +import {emptyFunction} from './functions'; export function provideLocation(req: IncomingMessage): ValueProvider { const protocol = 'encrypted' in req.socket ? 'https' : 'http'; const url: any = new URL(`${protocol}://${req.headers['host']}${req.url}`); - url.assign = empty; - url.reload = empty; - url.replace = empty; + url.assign = emptyFunction; + url.reload = emptyFunction; + url.replace = emptyFunction; url.ancestorOrigins = new (class extends Array implements DOMStringList { contains(): boolean { return false; diff --git a/projects/universal/src/utils/tests/functions.spec.ts b/projects/universal/src/utils/tests/functions.spec.ts index 27ed46a..f60aa8d 100644 --- a/projects/universal/src/utils/tests/functions.spec.ts +++ b/projects/universal/src/utils/tests/functions.spec.ts @@ -1,9 +1,10 @@ import { alwaysFalse, + alwaysNull, alwaysRejected, alwaysZero, - empty, emptyArray, + emptyFunction, emptyObject, identity, } from '../functions'; @@ -15,8 +16,8 @@ describe('Functions', () => { expect(identity(item)).toBe(item); }); - it('empty returns nothing', () => { - expect(empty()).toBeUndefined(); + it('emptyFunction returns nothing', () => { + expect(emptyFunction()).toBeUndefined(); }); it('emptyArray returns empty array', () => { @@ -31,6 +32,10 @@ describe('Functions', () => { expect(alwaysFalse()).toBe(false); }); + it('alwaysNull returns null', () => { + expect(alwaysNull()).toBeNull(); + }); + it('alwaysZero returns 0', () => { expect(alwaysZero()).toBe(0); });