UNPKG

@zendesk/laika

Version:

Test, mock, intercept and modify Apollo Client's operations — in both browser and unit tests!

655 lines 29.9 kB
"use strict"; /* eslint-disable @typescript-eslint/member-ordering,@typescript-eslint/no-shadow,max-classes-per-file */ /** * {@link Laika | `Laika`} is the place where most of the magic happens. * All the operations are routed through its Apollo Link, and Laika can decide what happens to them along the way. * By default every connection is passed through and no additional action is taken. * * If you're using createGlobalLaikaLink, an instance of Laika is by default installed as `laika` property * on the global object (most likely `window`), accessible as `window.laika` * or simply as `laika`. * * Key functionality: * * - {@link Laika.intercept | `laika.intercept()`}: * * If you use `jest`, you can think of laika like the `jest` global, * where the equivalent of `jest.fn()` is {@link Laika.intercept | `laika.intercept()`} * - {@link Laika.LogApi | `laika.log`} * * The other thing laika is responsible for is logging. * * Logging functionality is behind a separate API available under {@link Laika.LogApi | `laika.log`}. * * @packageDocumentation * @module Laika */ /* eslint-disable no-console */ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) { function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); } return new (P || (P = Promise))(function (resolve, reject) { function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } } function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } } function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); } step((generator = generator.apply(thisArg, _arguments || [])).next()); }); }; var __rest = (this && this.__rest) || function (s, e) { var t = {}; for (var p in s) if (Object.prototype.hasOwnProperty.call(s, p) && e.indexOf(p) < 0) t[p] = s[p]; if (s != null && typeof Object.getOwnPropertySymbols === "function") for (var i = 0, p = Object.getOwnPropertySymbols(s); i < p.length; i++) { if (e.indexOf(p[i]) < 0 && Object.prototype.propertyIsEnumerable.call(s, p[i])) t[p[i]] = s[p[i]]; } return t; }; var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); exports.Laika = void 0; const noop_1 = __importDefault(require("lodash/noop")); const core_1 = require("@apollo/client/core"); const codeGenerator_1 = require("./codeGenerator"); const constants_1 = require("./constants"); const getLogStyle_1 = require("./getLogStyle"); const hasOperation_1 = require("./hasOperation"); const linkUtils_1 = require("./linkUtils"); const observableUtils_1 = require("./observableUtils"); const CONSOLE_PADDING = 20; const CONSOLE_SUFFIX_PADDING = 60; const CONSOLE_INTERCEPT_PADDING = 10; const CONSOLE_TYPE_PADDING = 26; const CONSOLE_TIME_SINCE_PADDING = 5; const ONE_SECOND_IN_MS = 1000; const isPromiseLike = (value) => !!value && (typeof value === 'object' || typeof value === 'function') && typeof value.then === 'function'; const toError = (error) => error instanceof Error ? error : new Error(String(error)); const emitMockedResult = ({ mockedResult, operation, observer, completeAfterEmit, matcherFn, }) => { const emitResolvedResult = (resolvedResult) => { if (matcherFn && !matcherFn(operation)) { return; } const emit = () => { var _a; if (observer.closed) { return; } const emitValue = (0, linkUtils_1.getEmitValueFn)(resolvedResult); emitValue(operation, observer); if (completeAfterEmit && !observer.closed) { (_a = observer.complete) === null || _a === void 0 ? void 0 : _a.call(observer); } }; if (resolvedResult.delay && resolvedResult.delay > 0) { setTimeout(emit, resolvedResult.delay); } else { emit(); } }; if (isPromiseLike(mockedResult)) { void Promise.resolve(mockedResult) .then(emitResolvedResult) .catch((error) => { var _a; if (observer.closed) { return; } (_a = observer.error) === null || _a === void 0 ? void 0 : _a.call(observer, toError(error)); }); return; } emitResolvedResult(mockedResult); }; /** * Class responsible for managing interceptions. * By default a singleton is installed on `globalThis` (usually `window`) under `laika`. * * Read more in the {@link Laika | module page} or scroll down to see it's functionality. * * @example * ```js * laika.log.startLogging(); * ``` */ class Laika { constructor({ referenceName = 'laika', } = {}) { // logging API - for documentation see end of file /** * A set of functions that controls logging and recording of all (or selected) operations. * * Read more on the {@link Laika.LogApi | LogApi} page. * * @example * ```js * laika.log.startLogging(); * ``` */ this.log = { startLogging: (matcher) => { this.loggingMatcher = (0, linkUtils_1.getMatcherFn)(matcher); }, stopLogging: () => { this.loggingMatcher = constants_1.LOGGING_DISABLED_MATCHER; }, startRecording: (startingActionName, matcher) => { this.log.startLogging(matcher); this.isRecording = true; if (startingActionName) { this.log.markAction(startingActionName); } else { console.log(`It is recommended to name your actions before you take them during the recording by calling: ${this.referenceName}.log.markAction('opening the ticket')`); } }, stopRecording: () => { this.isRecording = false; }, resetRecording: () => { this.recording.length = 0; }, markAction: (actionName) => { this.actionName = actionName; if (this.isRecording) { const now = Date.now(); if (!this.firstCaptureTimestamp) this.firstCaptureTimestamp = now; this.recording.push({ type: 'marker', timeDelta: now - this.firstCaptureTimestamp, action: actionName, }); } else { throw new Error(`Sorry, you're not recording yet. log.startRecording() first :)`); } }, generateMockCode: (eventFilter, options) => (0, codeGenerator_1.generateCode)({ recording: this.recording, referenceName: this.referenceName, }, eventFilter, options), }; // private APIs below /** * @internal * */ this.interceptor = (operation, forward) => (0, observableUtils_1.mapObservable)(new core_1.Observable((observer) => { // we're subscribed, e.g. a component with useQuery was mounted or a refetch was requested operation.setContext({ subscribeTime: Date.now(), interceptMode: 'unset', }); let active = true; let passthroughSubscription; let lastMitm; const disablePassthrough = () => { let isSuccess = false; if (passthroughSubscription && !passthroughSubscription.closed) { passthroughSubscription.unsubscribe(); isSuccess = true; } passthroughSubscription = undefined; lastMitm = undefined; operation.setContext({ interceptMode: 'mock' }); return isSuccess; }; // currently mounted components would not work until they're remounted // hence the need for passthrough const enablePassthrough = (mitm) => { if (observer.closed || !active) { // no body is listening anymore, we can only clean-up: disablePassthrough(); return false; } if (passthroughSubscription) { if (mitm === lastMitm) { // no change needed, we're already subscribed to the right thing! return true; } // we need to re-subscribe because the sniffer has changed. // Keeping this dependency-free preserves Apollo 3 compatibility. disablePassthrough(); } // we 'unmock', i.e. we want to (re-)establish connectivity: const forward$ = mitm ? mitm({ operation, forward, observer, enablePassthrough, disablePassthrough, }) : forward(operation); operation.setContext({ interceptMode: mitm ? 'mitm' : 'passthrough' }); passthroughSubscription = forward$.subscribe(observer); lastMitm = mitm; return true; }; let cleanupFn = noop_1.default; const subscribeMeta = { operation, observer, forward, enablePassthrough, disablePassthrough, }; const interceptionBehavior = [...this.behaviors].find(({ matcher }) => matcher(operation)); if (interceptionBehavior) { cleanupFn = interceptionBehavior.onSubscribe(subscribeMeta); } else { this.unmatchedOperationOptions.add(subscribeMeta); // until mocking starts, we want to forward everything from the backend as is: enablePassthrough(); cleanupFn = () => { this.unmatchedOperationOptions.delete(subscribeMeta); const cleanup = this.cleanupFnPerSubscribeMeta.get(subscribeMeta); if (cleanup) cleanup(); }; } const logUnsubscribe = this.logSubscribe(subscribeMeta); return () => { logUnsubscribe(); cleanupFn(); disablePassthrough(); active = false; operation.setContext({ interceptMode: 'disposed' }); // TODO: does it make sense to complete the observer here? `if (!o.closed) o.complete()` }; }), this.getLogFunction({ operation, forward })); // interceptor-related properties: this.behaviors = new Set(); this.interceptRestoreFns = new Set(); this.unmatchedOperationOptions = new Set(); this.cleanupFnPerSubscribeMeta = new WeakMap(); // logging-related properties: this.loggingMatcher = constants_1.LOGGING_DISABLED_MATCHER; this.recording = []; this.actionName = 'first action'; this.isRecording = false; this.referenceName = referenceName; } intercept(matcher, connectFutureLinksOrMitmFn = false, keepNonSubscriptionConnectionsOpen = false) { const matcherFn = (0, linkUtils_1.getMatcherFn)(matcher); const resultFnLimitedSet = new Set(); const resultFnPersistentSet = new Set(); const onSubscribeCallbacks = new Set(); let passthrough = connectFutureLinksOrMitmFn; // we will still allow passthrough for normal requests (not subscriptions) // if a given request was not mocked, even when passthrough itself is falsy // this variable here tightens the pipe and stops the show completely: let passthroughFallbackAllowed = true; const passthroughEnablers = new Set(); const passthroughDisablers = new Set(); const observerToOperationMap = new Map(); const calledWithVariables = []; const onSubscribe = ({ operation, observer, enablePassthrough, disablePassthrough, }) => { observerToOperationMap.set(observer, operation); passthroughEnablers.add(enablePassthrough); passthroughDisablers.add(disablePassthrough); calledWithVariables.push(operation.variables); const cleanupFns = [...onSubscribeCallbacks] .map((callback) => callback({ operation, observer, removeCallback: () => onSubscribeCallbacks.delete(callback), })) .filter(Boolean); // sets initial passthrough state for this observer only (forwarding server responses): if (passthrough) { enablePassthrough(typeof passthrough === 'function' ? passthrough : undefined); } else { // likely no-op: disablePassthrough(); } let mockedResult; for (const resultGroup of [ ...resultFnLimitedSet, ...resultFnPersistentSet, ]) { const { resultOrFn: thisResultOrFn, matcher } = resultGroup; // eslint-disable-next-line no-continue if (!matcher(operation)) continue; mockedResult = typeof thisResultOrFn === 'function' ? thisResultOrFn(operation) : thisResultOrFn; if (typeof resultGroup.repeatTimes === 'number') { if (resultGroup.repeatTimes <= 1) { resultFnLimitedSet.delete(resultGroup); } else { resultGroup.repeatTimes--; } } break; } const queryIncludesSubscription = (0, hasOperation_1.hasSubscriptionOperation)(operation); if (mockedResult) { emitMockedResult({ mockedResult, operation, observer, completeAfterEmit: !queryIncludesSubscription && !keepNonSubscriptionConnectionsOpen, }); } else if (!passthrough && !queryIncludesSubscription && passthroughFallbackAllowed) { // we want to pass through a single request, but nothing beyond that enablePassthrough(({ disablePassthrough, forward }) => new core_1.Observable((observer) => { var _a; // this is the equivalent of take(1), which zen-observable does not offer: const innerSubscription = {}; let shouldDisableAfterSubscribe = false; innerSubscription.current = forward(operation).subscribe({ next: (remoteResult) => { var _a, _b; (_a = observer.next) === null || _a === void 0 ? void 0 : _a.call(observer, remoteResult); (_b = observer.complete) === null || _b === void 0 ? void 0 : _b.call(observer); if (innerSubscription.current) { innerSubscription.current.unsubscribe(); disablePassthrough(); } else { shouldDisableAfterSubscribe = true; } }, complete: () => { var _a; (_a = observer.complete) === null || _a === void 0 ? void 0 : _a.call(observer); }, error: (remoteError) => { var _a; (_a = observer.error) === null || _a === void 0 ? void 0 : _a.call(observer, remoteError); }, }); if (shouldDisableAfterSubscribe) { (_a = innerSubscription.current) === null || _a === void 0 ? void 0 : _a.unsubscribe(); disablePassthrough(); } })); } return () => { // we're unsubscribed, i.e. a component with useQuery was unmounted observerToOperationMap.delete(observer); passthroughEnablers.delete(enablePassthrough); passthroughDisablers.delete(disablePassthrough); cleanupFns.forEach((fn) => { if (typeof fn === 'function') fn(); }); }; }; const behavior = { matcher: matcherFn, onSubscribe, }; let restoreIntercept = noop_1.default; const ensureBehaviorRegistered = () => { // any queries made from now on will be matched against this behavior: this.behaviors.add(behavior); this.interceptRestoreFns.add(restoreIntercept); // but there might be currently subscribed operations, we want to take over those too: this.unmatchedOperationOptions.forEach((subscribeMeta) => { if (!matcherFn(subscribeMeta.operation)) return; this.unmatchedOperationOptions.delete(subscribeMeta); const cleanup = onSubscribe(subscribeMeta); this.cleanupFnPerSubscribeMeta.set(subscribeMeta, cleanup); }); }; const enablePassthroughInAllObservers = (mitm) => { if (!passthroughFallbackAllowed) return false; passthrough = mitm !== null && mitm !== void 0 ? mitm : true; const successList = [...passthroughEnablers].map((enablePassthrough) => enablePassthrough(mitm)); return successList.some(Boolean); }; const disablePassthroughInAllObservers = () => { passthrough = false; const successList = [...passthroughDisablers].map((disablePassthrough) => disablePassthrough()); return successList.some(Boolean); }; const resetIntercept = () => { resultFnLimitedSet.clear(); resultFnPersistentSet.clear(); onSubscribeCallbacks.clear(); calledWithVariables.length = 0; passthroughFallbackAllowed = true; passthrough = connectFutureLinksOrMitmFn; if (passthrough) { enablePassthroughInAllObservers(typeof passthrough === 'function' ? passthrough : undefined); } ensureBehaviorRegistered(); }; restoreIntercept = () => { resetIntercept(); enablePassthroughInAllObservers(); this.behaviors.delete(behavior); this.interceptRestoreFns.delete(restoreIntercept); }; ensureBehaviorRegistered(); // format of result should be the same as 'result' described here https://www.apollographql.com/docs/react/development-testing/testing/#defining-mocked-responses /** * See documentation of each function in {@link InterceptApi} */ const interceptApi = { get calls() { return [...calledWithVariables]; }, mockResult(resultOrFn, matcher) { ensureBehaviorRegistered(); disablePassthroughInAllObservers(); const matcherFn = (0, linkUtils_1.getMatcherFn)(matcher); resultFnPersistentSet.add({ resultOrFn: resultOrFn, matcher: matcherFn, }); return interceptApi; }, mockResultOnce(resultOrFn, matcher) { ensureBehaviorRegistered(); disablePassthroughInAllObservers(); const matcherFn = (0, linkUtils_1.getMatcherFn)(matcher); resultFnLimitedSet.add({ resultOrFn: resultOrFn, matcher: matcherFn, repeatTimes: 1, }); return interceptApi; }, waitForActiveSubscription() { return __awaiter(this, void 0, void 0, function* () { ensureBehaviorRegistered(); if (observerToOperationMap.size > 0) return undefined; return interceptApi.waitForNextSubscription().then(noop_1.default); }); }, waitForNextSubscription() { return __awaiter(this, void 0, void 0, function* () { ensureBehaviorRegistered(); return new Promise((resolve) => { interceptApi.onSubscribe((_a) => { var { removeCallback } = _a, data = __rest(_a, ["removeCallback"]); removeCallback(); resolve(data); }); }); }); }, fireSubscriptionUpdate(resultOrFn, fireMatcher) { ensureBehaviorRegistered(); if (observerToOperationMap.size === 0) { const operationName = (typeof matcher === 'object' && matcher.operationName) || (typeof fireMatcher === 'object' && fireMatcher.operationName) || (typeof matcher === 'object' && matcher.operation && (0, hasOperation_1.getOperationNameFromDocument)(matcher.operation)) || (typeof fireMatcher === 'object' && fireMatcher.operation && (0, hasOperation_1.getOperationNameFromDocument)(fireMatcher.operation)); throw new Error(`Cannot fire a subscription update, as there is nothing listening to ${operationName ? `'${operationName}'.` : 'this Apollo operation.'}`); } const fireMatcherFn = (0, linkUtils_1.getMatcherFn)(fireMatcher); observerToOperationMap.forEach((operation, observer) => { const result = typeof resultOrFn === 'function' ? resultOrFn(operation) : resultOrFn; emitMockedResult({ mockedResult: result, operation, observer, completeAfterEmit: false, matcherFn: fireMatcherFn, }); }); return interceptApi; }, onSubscribe(callback) { ensureBehaviorRegistered(); onSubscribeCallbacks.add(callback); return () => { onSubscribeCallbacks.delete(callback); }; }, disableNetworkFallback() { ensureBehaviorRegistered(); passthroughFallbackAllowed = false; }, allowNetworkFallback() { passthroughFallbackAllowed = true; }, mockReset() { resetIntercept(); return interceptApi; }, mockRestore: restoreIntercept, }; return interceptApi; } /** * Modify backend (or mocked) responses before they reach subscribers. * * @param matcher Leave undefined if you want to intercept every operation. Otherwise provide either a {@link MatcherFn | matcher function} or a `MatcherObject` with properties like `clientName` and/or a partial set of `variables` that have to match for a given operation to be intercepted. * @param mapFn Mapping function to alter the responses. */ modifyRemote(matcher, mapFn) { const interceptor = this.intercept(matcher, ({ forward, operation }) => (0, observableUtils_1.mapObservable)(forward(operation), (result) => mapFn(result, operation))); return { restore: interceptor.mockRestore, }; } /** * Removes every intercept created by this {@link Laika | Laika} instance. * Useful in `afterEach` hooks to keep browser and component tests isolated. * * Restoring intercepts re-enables passthrough for active observers, but existing * Apollo subscriptions may still need to be remounted by the test harness before * they can be intercepted again with a different scenario. */ mockRestoreAll() { ; [...this.interceptRestoreFns].forEach((restoreIntercept) => { restoreIntercept(); }); } /** * Use this function to create an Apollo Link that uses this Laika instance. * Useful in unit tests. * @param onRequest */ createLink(onRequest) { return new core_1.ApolloLink((operation, forward) => { if (!forward) { throw new Error('LaikaLink cannot be used as a terminating link!'); } onRequest === null || onRequest === void 0 ? void 0 : onRequest(operation, forward); return this.interceptor(operation, forward); }); } // logging functionality: /** * @param input */ getLogFunction({ operation, }) { return (result) => { if (!this.loggingMatcher(operation)) return result; const hasMutation = (0, hasOperation_1.hasMutationOperation)(operation); const type = (0, hasOperation_1.hasSubscriptionOperation)(operation) ? 'push' : hasMutation ? 'response:mutation' : 'response:query'; const { clientName: unsafeClientName, feature: unsafeFeature, subscribeTime, interceptMode: unsafeInterceptMode, } = operation.getContext(); const clientName = unsafeClientName ? String(unsafeClientName) : 'client'; const feature = unsafeFeature ? String(unsafeFeature) : undefined; const interceptMode = String(unsafeInterceptMode); const { operationName } = operation; const now = Date.now(); if (this.isRecording) { if (!this.firstCaptureTimestamp) this.firstCaptureTimestamp = now; this.recording.push({ clientName, timeDelta: now - this.firstCaptureTimestamp, operationName: operation.operationName, variables: operation.variables, feature, type, result, action: this.actionName, }); } const timeSinceSubscribe = subscribeTime ? `${((now - subscribeTime) / ONE_SECOND_IN_MS).toFixed(1)}s` : '?s'; const suffixText = `${operationName}${feature ? ` (${feature})` : ''}`; console.log(`${this.isRecording ? '🔴 REC:GQL' : '🔵 LOG:GQL'} %c${clientName.padStart(CONSOLE_PADDING, ' ')}: ${type.padEnd(CONSOLE_PADDING, ' ')} ${timeSinceSubscribe.padStart(CONSOLE_TIME_SINCE_PADDING, ' ')} ${interceptMode.padEnd(CONSOLE_INTERCEPT_PADDING, ' ')} ${suffixText.padEnd(CONSOLE_SUFFIX_PADDING, ' ')}\t%o`, (0, getLogStyle_1.getLogStyle)(operationName), { operation, result }); return result; }; } /** * @param data */ logSubscribe({ operation }) { if (!this.loggingMatcher(operation)) return noop_1.default; const hasMutation = (0, hasOperation_1.hasMutationOperation)(operation); const type = (0, hasOperation_1.hasSubscriptionOperation)(operation) ? 'subscription' : hasMutation ? 'mutation' : 'query'; const { clientName: unsafeClientName, feature: unsafeFeature, interceptMode: unsafeInterceptMode, } = operation.getContext(); const clientName = String(unsafeClientName); const feature = String(unsafeFeature); const interceptMode = String(unsafeInterceptMode); const { operationName } = operation; if (type !== 'subscription') { // less noisy console return noop_1.default; } const suffixText = `${operationName}${feature ? ` (${feature})` : ''}`; const mainText = `${clientName.padStart(CONSOLE_PADDING, ' ')}: ${type.padEnd(CONSOLE_TYPE_PADDING, ' ')} ${interceptMode.padEnd(CONSOLE_INTERCEPT_PADDING, ' ')} ${suffixText.padEnd(CONSOLE_SUFFIX_PADDING, ' ')}`; console.log(`🚀 SUB:GQL %c${mainText}\t%o`, (0, getLogStyle_1.getLogStyle)(operationName), { operation, }); return () => { console.log(`🏁 END:GQL %c${mainText}\t%o`, (0, getLogStyle_1.getLogStyle)(operationName), { operation, }); }; } } exports.Laika = Laika; //# sourceMappingURL=laika.js.map