@zendesk/laika
Version:
Test, mock, intercept and modify Apollo Client's operations — in both browser and unit tests!
582 lines • 26.5 kB
JavaScript
/* 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;
};
import noop from 'lodash/noop';
import { ApolloLink, Observable, } from '@apollo/client/core';
import { generateCode } from './codeGenerator';
import { LOGGING_DISABLED_MATCHER } from './constants';
import { getLogStyle } from './getLogStyle';
import { hasMutationOperation, hasSubscriptionOperation } from './hasOperation';
import { getEmitValueFn, getMatcherFn } from './linkUtils';
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;
/**
* 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();
* ```
*/
export 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 = getMatcherFn(matcher);
},
stopLogging: () => {
this.loggingMatcher = 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) => generateCode({
recording: this.recording,
referenceName: this.referenceName,
}, eventFilter, options),
};
// private APIs below
/**
* @internal
* */
this.interceptor = (operation, forward) => new 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
// could be mitigated with a switchMap from rxjs, but we don't have rxjs 🤷♂️
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;
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()`
};
}).map(this.getLogFunction({ operation, forward }));
// interceptor-related properties:
this.behaviors = new Set();
this.unmatchedOperationOptions = new Set();
this.cleanupFnPerSubscribeMeta = new WeakMap();
// logging-related properties:
this.loggingMatcher = LOGGING_DISABLED_MATCHER;
this.recording = [];
this.actionName = 'first action';
this.isRecording = false;
this.referenceName = referenceName;
}
/**
* Provides functionality to intercept, and optionally mock or modify each operation's subscription.
* The API returned is heavily inspired on jest's mocking functionality (`jest.fn()`)
* and is described in length here: {@link InterceptApi}.
*
* Every interceptor you create should be as specific as needed in a given session.
* At the very least, ensure the order of creating interceptors is from most specific, to least specific.
*
* This is because any operations that are executed by your client will end up
* being intercepted by the **first** interceptor that matches
* the constraints of the {@link Matcher}.
*
* See [*Pitfalls*](pitfalls.md) for more information.
*
* @param matcher [[include:matcher.md]]
* @param connectFutureLinksOrMitmFn If true, future links will still be called (e.g. reach the backend) and return responses. If set to a function, can serve for man-in-the-middle tinkering with the result.
* @param keepNonSubscriptionConnectionsOpen If true, queries and mutations will behave a little like subscriptions, in that you will be able to fire updates even after the initial response. Experimental.
* @example
* ```js
* const getActiveUsersInterceptor = laika.intercept({
* clientName: 'users',
* operationName: 'getActiveUsers',
* });
* ```
*/
intercept(matcher, connectFutureLinksOrMitmFn = false, keepNonSubscriptionConnectionsOpen = false) {
const matcherFn = 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, }) => {
var _a;
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 = hasSubscriptionOperation(operation);
if (mockedResult) {
const emitValue = getEmitValueFn(mockedResult);
emitValue(operation, observer);
if (!queryIncludesSubscription &&
!observer.closed &&
!keepNonSubscriptionConnectionsOpen) {
(_a = observer.complete) === null || _a === void 0 ? void 0 : _a.call(observer);
}
}
else if (!passthrough &&
!queryIncludesSubscription &&
passthroughFallbackAllowed) {
// we want to pass through a single request, but nothing beyond that
enablePassthrough(({ disablePassthrough, forward }) => new Observable((observer) => {
// this is the equivalent of take(1), which zen-observable does not offer:
const innerSubscription = forward(operation).subscribe({
next: (remoteResult) => {
observer.next(remoteResult);
observer.complete();
innerSubscription.unsubscribe();
disablePassthrough();
},
complete: () => {
if (!observer.complete)
observer.complete();
},
error: (remoteError) => {
observer.error(remoteError);
},
});
}));
}
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,
};
const ensureBehaviorRegistered = () => {
// any queries made from now on will be matched against this behavior:
this.behaviors.add(behavior);
// 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);
});
};
ensureBehaviorRegistered();
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);
};
// 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 = getMatcherFn(matcher);
resultFnPersistentSet.add({ resultOrFn, matcher: matcherFn });
return interceptApi;
},
mockResultOnce(resultOrFn, matcher) {
ensureBehaviorRegistered();
disablePassthroughInAllObservers();
const matcherFn = getMatcherFn(matcher);
resultFnLimitedSet.add({
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);
});
},
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);
throw new Error(`Cannot fire a subscription update, as there is nothing listening to ${operationName ? `'${operationName}'.` : 'this Apollo operation.'}`);
}
observerToOperationMap.forEach((operation, observer) => {
const result = typeof resultOrFn === 'function'
? resultOrFn(operation)
: resultOrFn;
const emitValue = getEmitValueFn(result, getMatcherFn(fireMatcher));
emitValue(operation, observer);
});
return interceptApi;
},
onSubscribe(callback) {
ensureBehaviorRegistered();
onSubscribeCallbacks.add(callback);
return () => {
onSubscribeCallbacks.delete(callback);
};
},
disableNetworkFallback() {
ensureBehaviorRegistered();
passthroughFallbackAllowed = false;
},
allowNetworkFallback() {
passthroughFallbackAllowed = true;
},
mockReset() {
resultFnLimitedSet.clear();
resultFnPersistentSet.clear();
onSubscribeCallbacks.clear();
calledWithVariables.length = 0;
passthroughFallbackAllowed = true;
passthrough = connectFutureLinksOrMitmFn;
if (passthrough) {
enablePassthroughInAllObservers(typeof passthrough === 'function' ? passthrough : undefined);
}
ensureBehaviorRegistered();
return interceptApi;
},
mockRestore: () => {
interceptApi.mockReset();
enablePassthroughInAllObservers();
this.behaviors.delete(behavior);
},
};
return interceptApi;
}
/**
* Modify backend (or mocked) responses before they reach subscribers.
*
* @param matcher [[include:matcher.md]]
* @param mapFn Mapping function to alter the responses.
*/
modifyRemote(matcher, mapFn) {
const interceptor = this.intercept(matcher, ({ forward, operation }) => forward(operation).map((result) => mapFn(result, operation)));
return {
restore: interceptor.mockRestore,
};
}
/**
* Use this function to create an Apollo Link that uses this Laika instance.
* Useful in unit tests.
* @param onRequest
*/
createLink(onRequest) {
return new 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 = hasMutationOperation(operation);
const type = 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`, getLogStyle(operationName), { operation, result });
return result;
};
}
/**
* @param data
*/
logSubscribe({ operation }) {
if (!this.loggingMatcher(operation))
return noop;
const hasMutation = hasMutationOperation(operation);
const type = 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;
}
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`, getLogStyle(operationName), {
operation,
});
return () => {
console.log(`🏁 END:GQL %c${mainText}\t%o`, getLogStyle(operationName), {
operation,
});
};
}
}
//# sourceMappingURL=laika.js.map