@zendesk/laika
Version:
Test, mock, intercept and modify Apollo Client's operations — in both browser and unit tests!
965 lines (898 loc) • 33 kB
text/typescript
/* 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 */
import noop from 'lodash/noop'
import {
ApolloLink,
FetchResult,
NextLink,
Observable,
Observer,
Operation,
} from '@apollo/client/core'
import type { GenerateCodeOptions } from './codeGenerator'
import { generateCode } from './codeGenerator'
import { LOGGING_DISABLED_MATCHER } from './constants'
import { getLogStyle } from './getLogStyle'
import { hasMutationOperation, hasSubscriptionOperation } from './hasOperation'
import { getEmitValueFn, getMatcherFn } from './linkUtils'
import type {
Behavior,
EventFilterFn,
FetchResultSubscriptionObserver,
InterceptorFn,
ManInTheMiddleFn,
Matcher,
MatcherFn,
OnSubscribe,
OnSubscribeCallback,
PassthroughDisableFn,
PassthroughEnableFn,
RecordingElement,
Result,
ResultOrFn,
SubscribeMeta,
Subscription,
Variables,
} from './typedefs'
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 = 1_000
/**
* 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 {
private readonly referenceName: string
constructor({
referenceName = 'laika',
}: {
referenceName?: string
} = {}) {
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?: Matcher | undefined,
connectFutureLinksOrMitmFn:
| (ManInTheMiddleFn | boolean)
| undefined = false,
keepNonSubscriptionConnectionsOpen = false,
): InterceptApi {
const matcherFn: MatcherFn = getMatcherFn(matcher)
const resultFnLimitedSet: Set<{
resultOrFn: ResultOrFn
matcher: MatcherFn
repeatTimes?: number
}> = new Set()
const resultFnPersistentSet: Set<{
resultOrFn: ResultOrFn
matcher: MatcherFn
repeatTimes?: number
}> = new Set()
const onSubscribeCallbacks: Set<OnSubscribeCallback> = 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: Set<PassthroughEnableFn> = new Set()
const passthroughDisablers: Set<PassthroughDisableFn> = new Set()
const observerToOperationMap: Map<
FetchResultSubscriptionObserver,
Operation
> = new Map()
const calledWithVariables: Variables[] = []
const onSubscribe: OnSubscribe = ({
operation,
observer,
enablePassthrough,
disablePassthrough,
}) => {
observerToOperationMap.set(observer, operation)
passthroughEnablers.add(enablePassthrough)
passthroughDisablers.add(disablePassthrough)
calledWithVariables.push(operation.variables)
const cleanupFns: ((() => void) | void)[] = [...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: Result | undefined
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
) {
observer.complete?.()
}
} 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: 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: PassthroughEnableFn = (mitm) => {
if (!passthroughFallbackAllowed) return false
passthrough = mitm ?? true
const successList = [...passthroughEnablers].map((enablePassthrough) =>
enablePassthrough(mitm),
)
return successList.some(Boolean)
}
const disablePassthroughInAllObservers: PassthroughDisableFn = () => {
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: InterceptApi = {
get calls() {
return [...calledWithVariables]
},
mockResult(resultOrFn: ResultOrFn, matcher?: Matcher | undefined) {
ensureBehaviorRegistered()
disablePassthroughInAllObservers()
const matcherFn = getMatcherFn(matcher)
resultFnPersistentSet.add({ resultOrFn, matcher: matcherFn })
return interceptApi
},
mockResultOnce(resultOrFn: ResultOrFn, matcher?: Matcher | undefined) {
ensureBehaviorRegistered()
disablePassthroughInAllObservers()
const matcherFn = getMatcherFn(matcher)
resultFnLimitedSet.add({
resultOrFn,
matcher: matcherFn,
repeatTimes: 1,
})
return interceptApi
},
async waitForActiveSubscription() {
ensureBehaviorRegistered()
if (observerToOperationMap.size > 0) return undefined
return interceptApi.waitForNextSubscription().then(noop)
},
async waitForNextSubscription() {
ensureBehaviorRegistered()
return new Promise((resolve) => {
interceptApi.onSubscribe(({ removeCallback, ...data }) => {
removeCallback()
resolve(data)
})
})
},
fireSubscriptionUpdate(resultOrFn: ResultOrFn, fireMatcher?: Matcher) {
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: OnSubscribeCallback) {
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: Matcher | undefined,
mapFn: (result: FetchResult, operation: Operation) => FetchResult,
) {
const interceptor = this.intercept(matcher, ({ forward, operation }) =>
forward(operation).map((result) => mapFn(result, operation)),
)
return {
restore: interceptor.mockRestore,
}
}
// 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();
* ```
*/
log: LogApi = {
startLogging: (matcher?: Matcher) => {
this.loggingMatcher = getMatcherFn(matcher)
},
stopLogging: () => {
this.loggingMatcher = LOGGING_DISABLED_MATCHER
},
startRecording: (startingActionName?: string, matcher?: 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: string) => {
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?: EventFilterFn,
options?: GenerateCodeOptions,
) =>
generateCode(
{
recording: this.recording,
referenceName: this.referenceName,
},
eventFilter,
options,
),
}
/**
* Use this function to create an Apollo Link that uses this Laika instance.
* Useful in unit tests.
* @param onRequest
*/
createLink(onRequest?: (operation: Operation, forward: NextLink) => void) {
return new ApolloLink((operation, forward) => {
if (!forward) {
throw new Error('LaikaLink cannot be used as a terminating link!')
}
onRequest?.(operation, forward)
return this.interceptor(operation, forward)
})
}
// private APIs below
/**
* @internal
* */
interceptor: InterceptorFn = (operation, forward) =>
new Observable<FetchResult>((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: Subscription | undefined
let lastMitm: ManInTheMiddleFn | undefined
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?: ManInTheMiddleFn | undefined) => {
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: () => void = 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:
private readonly behaviors: Set<Behavior> = new Set()
private readonly unmatchedOperationOptions: Set<SubscribeMeta> = new Set()
private readonly cleanupFnPerSubscribeMeta: WeakMap<
SubscribeMeta,
() => void
> = new WeakMap()
// logging functionality:
/**
* @param input
*/
private getLogFunction({
operation,
}: {
operation: Operation
forward: NextLink
}): (result: FetchResult) => FetchResult {
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
*/
private logSubscribe({ operation }: SubscribeMeta): () => void {
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,
})
}
}
// logging-related properties:
private loggingMatcher: MatcherFn = LOGGING_DISABLED_MATCHER
private firstCaptureTimestamp: number | undefined
private recording: RecordingElement[] = []
private actionName = 'first action'
private isRecording = false
}
export declare abstract class LogApi {
/** @ignore */
constructor()
/**
* Starts logging every matching operation and subscription to the console.
* If you did not provide a matcher, it will log everything.
* You will see queries, mutations, and subscription pushes along with their data.
*
* 
*/
startLogging(matcher?: Matcher | undefined): void
/**
* Stops logging to the console.
*/
stopLogging(): void
/**
* Starts the recording process. Every result will be saved until you run `log.stopRecording()`.
*
* 
*
* @param startingActionName Name what you are about to do. For example "opening a new ticket".
* @param matcher A matcher object or function to record only the events that you are interested in, for example `{operationName: 'getColors', clientName: 'backend1'}` will record only `'getColors'` operations.
*/
startRecording(
startingActionName?: string | undefined,
matcher?: Matcher | undefined,
): void
/**
* Pauses recording without clearing what was recorded so far.
*/
stopRecording(): void
/**
* Resets the recording in preparation of another one.
*/
resetRecording(): void
/**
* Use this function to mark a new action if recording a sequence of events.
*
* These will show up when you generate mock code as comments,
* so you can more easily orient yourself in it.
*
* @param actionName Describe what action you will be performing, e.g. 'opening the ticket'
* @example
* ```js
* log.markAction('opening the ticket');
* // click around the site
* log.markAction('changing the assignee');
* ```
*/
markAction(actionName: string): void
/**
* Returns a code snippet that will help you reproduce your recording without hitting actual backends.
* @param eventFilter Optionally provide a function that will only keep the events you are interested in.
* @param options Optionally provide code generation options to customize the output.
*/
generateMockCode(
eventFilter?: EventFilterFn,
options?: GenerateCodeOptions,
): string
}
/**
* This is the mocking API that is returned after running {@link Laika.intercept | `intercept()`} on the {@link Laika | Laika}.
*
* The API is chainable, with the exception of `mockRestore()`.
*
* Inspired by `jest.fn()`.
*/
export declare abstract class InterceptApi {
/** @ignore */
constructor()
/**
* An array containing the `variables` from subsequent operations that passed through this intercept.
*
* Similar to `jest.fn().mock.calls`.
*/
readonly calls: readonly Variables[]
/**
* Sets the mock data that will be used as a default response to intercepted queries and mutations.
* If used for subscriptions, will push data immediately.
*
* Similar to `jest.fn().mockReturnValue(...)`.
*
* @param resultOrFn [[include:result-or-fn.md]]
* @param matcher [[include:mock-matcher.md]]
* @example
* Always respond with the mock to all queries/mutations intercepted
* ```js
* const intercept = laika.intercept({operationName: 'getUsers'});
* intercept.mockResult(
* {result: {data: {users: [{id: 1, name: 'Mouse'}, {id: 2, name: 'Bamboo'}]}}},
* );
* ```
* @example
* Respond with an error, but only when the operations's variables contain `{userGroup: 'elephants'}`
* ```js
* const intercept = laika.intercept({operationName: 'getUsers'});
* intercept.mockResult(
* {error: new Error(`oops, server blew up from all the elephants stomping!`)},
* {variables: {userGroup: 'elephants'}}
* );
* ```
* @example
* Respond with a customized error based on the variables:
* ```js
* const intercept = laika.intercept({operationName: 'getUsers'});
* intercept.mockResult(
* ({variables}) => ({error: new Error(`oops, server blew up from all the ${variables.userGroup} stomping!`)})
* );
* ```
*/
mockResult(
resultOrFn: ResultOrFn,
matcher?: Matcher | undefined,
): InterceptApi
/**
* Sets the mock data that will be used as the *next* response to matching intercepted queries/mutations.
* If used for subscription operations, will immediately push provided data to the next matching request.
* Works the same as {@link InterceptApi.mockResult | `mockResult`},
* except that as soon as a matching result is found in the queue of mocks, it will not be sent again.
*
* Can be run multiple times and will send responses in order in which `mockResultOnce` was called.
*
* @param resultOrFn [[include:result-or-fn.md]]
* @param matcher [[include:mock-matcher.md]]
* @example
* Respond with the mock to the first intercepted operation with the name `getUsers`,
* then with a different mock the second time that operation is intercepted.
* ```js
* const intercept = laika.intercept({operationName: 'getUsers'});
* intercept
* .mockResultOnce(
* {result: {data: {users: [{id: 1, name: 'Mouse'}, {id: 2, name: 'Bamboo'}]}}},
* );
* .mockResultOnce(
* {result: {data: {users: [{id: 9, name: 'Ox'}, {id: 10, name: 'Fox'}]}}},
* );
* ```
*/
mockResultOnce(
resultOrFn: ResultOrFn,
matcher?: Matcher | undefined,
): InterceptApi
/**
* In case of GraphQL subscriptions, will return synchronously if at least
* one intercepted subscription is already active.
* In other cases returns a `Promise` and behaves the same way as {@link InterceptApi.waitForNextSubscription | `waitForNextSubscription()`}.
*/
waitForActiveSubscription(): Promise<void> | undefined
/**
* Returns a Promise that will resolve when the *next* operation is run.
* This translates to whenever a query/mutation is run, or whenever the *next* subscription is made.
*/
waitForNextSubscription(): Promise<{
operation: Operation
observer: Observer<FetchResult>
}>
/**
* Push data to an already active `subscription`-type operation.
* Will throw if there are no subscribers (e.g. active `useQuery` hooks).
*
* Works similarly to {@link InterceptApi.mockResult | `mockResult(...)`}, but the listener
* is being fed the new data upon execution.
*
* Combine with {@link InterceptApi.waitForActiveSubscription | `waitForActiveSubscription()`}
* to ensure a subscription is active before calling.
*
* @param resultOrFn [[include:result-or-fn.md]]
* @param fireMatcher [[include:mock-matcher.md]]
* @example
* Push new information to a live feed:
* ```js
* const intercept = laika.intercept({operationName: 'getActiveUsersCount'});
* await intercept.waitForActiveSubscription();
* intercept.fireSubscriptionUpdate(
* {result: {data: {count: 10}}},
* );
* // e.g. assert the count displayed on the page is in fact 10
* intercept.fireSubscriptionUpdate(
* {result: {data: {count: 0}}},
* );
* // e.g. assert the page shows "there are no active users currently on the page"
* ```
*/
fireSubscriptionUpdate(
resultOrFn: ResultOrFn,
fireMatcher?: Matcher,
): InterceptApi
/**
* Add a callback that will fire every time a component connects to the query (i.e. mounts).
* You may return a clean-up function which will be run when the query disconnects.
*/
onSubscribe(callback: OnSubscribeCallback): (() => void) | void
/**
* If you invoke this and do not setup any mocked results, your intercepted queries will not respond,
* i.e. hang in a "loading" state, until you fire the data event manually
* (e.g. in a custom callback defined in {@link InterceptApi.onSubscribe `onSubscribe(callback)`}.
*
* Does not affect `subscription` operations which will not reach the backend regardless of this setting (unless the `connectFutureLinksOrMitmFn` argument was set).
*
* Opposite of {@link InterceptApi.allowNetworkFallback `allowNetworkFallback()`}.
*/
disableNetworkFallback(): void
/**
* This restores the default behavior: both queries and mutations
* will be passed to future links (e.g. your backend) and back to the components.
*
* Does not affect `subscription` operations which will not reach the backend regardless of this setting (unless the `connectFutureLinksOrMitmFn` argument was set).
*
* Opposite of {@link InterceptApi.disableNetworkFallback `disableNetworkFallback()`}.
*/
allowNetworkFallback(): void
/**
* Resets the mock configuration to its initial state and reenables the intercept if disabled by {@link InterceptApi.mockRestore `mockRestore()`}.
*/
mockReset(): InterceptApi
/**
* Removes the intercept completely and re-establishes connectivity in current and _future_ intercepted operations.
* Note the word _future_. Any connections that were established prior to running this command,
* will not automatically switch over to other mocks. This will mostly affect subscriptions.
* Ideally, keep a reference to the original intercept throughout the duration of your session
* and simply `intercept.reset()` if you need to restore connectivity or setup a different scenario.
*/
mockRestore(): void
}