@zendesk/laika
Version:
Test, mock, intercept and modify Apollo Client's operations — in both browser and unit tests!
650 lines (587 loc) • 21.5 kB
text/typescript
/* eslint-disable @typescript-eslint/no-shadow,@typescript-eslint/triple-slash-reference */
/// <reference types="typescript/lib/lib.es2021" />
import camelCase from 'lodash/camelCase'
import flatten from 'lodash/flatten'
import groupBy from 'lodash/groupBy'
import sortBy from 'lodash/sortBy'
import uniq from 'lodash/uniq'
import {
forEachDeep,
indexOfWord,
startsWithNumber,
} from './codeGeneratorUtils'
import type {
ArgsType,
EventFilterFn,
RecordingElement,
RecordingElementWithFixtureData,
RecordingElementWithFixtureMeta,
RecordingPoint,
RecordingPointWithFixtureData,
Replacements,
} from './typedefs'
export type GenerateCodeArgs = ArgsType<typeof generateCode>
export type GenerateCodeOptions = GenerateCodeArgs[2]
const MAX_VALUE_LENGTH = 100
const MAX_PHRASE_TO_VALUE_LENGTH_RATIO = 0.3
const MAX_REFERENCED_VALUE_LENGTH = 5
const ONE_SECOND_MS = 1_000
// note: This file needs a little work to make it more readable.
// Because it's not the mission critical part of the package,
// least time was devoted to it.
// Please help to improve it!
export const generateCode = (
{
recording: inputRecording,
referenceName,
}: { recording: RecordingElement[]; referenceName: string },
eventFilter: EventFilterFn = () => true,
{
/** Keys that will never be replaced with variables. By default only `['__typename']`. */
nonVariableKeys = ['__typename'],
substringVariables = true,
minRepeatCount = 1,
notExtractablePartialPhrases = [
'System',
'Type',
'Status',
'ticket',
'subject',
'status',
],
deprioritizedKeys = ['value', 'title', 'values'],
skipDeduplicationValues = ['-1', '0'],
skipDeduplicationKey = ['at'],
skipPathNames = ['node', 'edges', 'pageInfoTotal'],
} = {},
) => {
const recording = inputRecording.filter(eventFilter)
const phrasesList: Map<
string,
{ key: string; value: unknown; event: RecordingPoint }[]
> = new Map()
const getTraverser =
(event: RecordingPoint, skipPathRootsCount: number | undefined = 0) =>
({
path,
value,
key,
}: {
path: string[]
value: unknown
key?: string
}) => {
if (!key || nonVariableKeys.includes(key)) return undefined
// unfortunately some backends represent the same IDs as strings, while others as numbers
const valueAsString = String(value)
if (valueAsString.length === 0) return value
const names: {
key: string
value: unknown
event: RecordingPoint
}[] = phrasesList.get(valueAsString) ?? []
if (
names.every(
({ event: ev }) =>
ev.clientName !== event.clientName ||
ev.operationName !== event.operationName,
)
) {
const relevantPath = path
.slice(skipPathRootsCount)
.filter((name) => !skipPathNames.includes(name))
// TODO: add __typename for better variable names
names.push({
key: relevantPath.join('.'),
value,
event,
})
phrasesList.set(valueAsString, names)
}
return undefined
}
recording.forEach((event) => {
if (event.type === 'marker') return
// deeply traverse these variables:
forEachDeep({ value: event.variables }, getTraverser(event))
// deeply traverse this result:
forEachDeep({ value: event.result }, getTraverser(event, 2))
})
const existingFixtureNames: Set<string> = new Set()
const existingStringifiedFixturesToNames = new Map<string, string>()
// sort this map from longest value to shortest to get most accurate results
const phrases = new Map<string, { key: string; value: unknown }[]>(
sortBy([...phrasesList], [([value]) => -value.length]),
)
const allValueAsStringToVariableName: [string, string][] = []
const allReplacedVariables: [string, { key: string; value: unknown }[]][] = []
// second time we iterate we'll have collected data from all recorded events (and thus repetitions)
const recordingWithFixtureMeta: RecordingElementWithFixtureMeta[] =
recording.map((event) => {
if (event.type === 'marker') return event
const replacedVariables = new Map<
string,
{ key: string; value: unknown }[]
>()
const variableValues = new Map<
string,
{
replacement: string
placeholder: string
originalValue: boolean | number | string
partial?: boolean
valueAsString: string
variableName: string
skipDeduplication: boolean
}[]
>()
let duplicateSuffix = 1
// TODO: use lodash recursive mapValues first, flattened by path, unflatten and JSON.stringify ready-to-use object
let stringifiedResult = JSON.stringify(
event.result,
(key, value) => {
if (
!key ||
nonVariableKeys.includes(key) ||
(typeof value !== 'string' &&
typeof value !== 'number' &&
typeof value !== 'boolean')
) {
return value as unknown
}
const valueAsString = String(value)
if (valueAsString.length === 0) return value
const skipDeduplication =
typeof value === 'boolean' ||
skipDeduplicationValues.includes(valueAsString) ||
skipDeduplicationKey.includes(key)
/**
* @param {{phrase?: string, value: string | number | boolean, names: Array<{key: string, value: string | number | boolean}>, skipDeduplication?: boolean}} config
*/
const createVariable = ({
names,
phrase,
value,
skipDeduplication = false,
}: {
phrase?: string
value: boolean | number | string
names: { key: string; value: unknown }[]
skipDeduplication?: boolean
}): string => {
const valueAsString = String(value)
// let's take the longest (or best) name from all duplicates (probably most descriptive)
// TODO: the fallback to 'key' should be the full path, not just the leaf name - but we don't have that during JSON.stringify :( otherwise those keys that skip deduplication don't have consistent naming
const [{ key: name = key } = {}] = sortBy(names, [
({ key: thisKey }) => {
const parts = thisKey.split('.')
return deprioritizedKeys.some((deprioKey) =>
parts.includes(deprioKey),
)
? Number.POSITIVE_INFINITY
: // lower priority to keys with numbers
parts.some((part) => !Number.isNaN(Number(part)))
? thisKey.length
: -thisKey.length
},
])
// if first letter is a number, need to prefix for a valid variable name:
const variableNameBase = `${
startsWithNumber(name) ? '_' : ''
}${camelCase(name)}`
let variableName = variableNameBase
let suffixNumber = 2
while (
replacedVariables.has(variableName) &&
(skipDeduplication ||
replacedVariables.get(variableName)?.[0]?.value !== value)
) {
variableName = `${variableNameBase}${suffixNumber++}`
}
replacedVariables.set(variableName, names)
const placeholder = `______${
phrase ? 'PARTIAL' : 'VARIABLE'
}_${variableName}_${duplicateSuffix++}______`
const replacements = variableValues.get(valueAsString) ?? []
replacements.push({
partial: Boolean(phrase),
placeholder,
replacement: phrase
? `\${${variableName}}`
: typeof value === 'number'
? `Number(${variableName})`
: typeof value === 'boolean'
? variableName
: `\`\${${variableName}}\``,
variableName,
skipDeduplication,
originalValue: value,
valueAsString: phrase ?? valueAsString,
})
variableValues.set(valueAsString, replacements)
return phrase
? valueAsString.replaceAll(phrase, placeholder)
: placeholder
}
const names: {
key: string
value: unknown
}[] = skipDeduplication
? [{ key, value }]
: (phrases.get(valueAsString) ?? [])
if (
(valueAsString.length < MAX_VALUE_LENGTH &&
names.length >= minRepeatCount) ||
skipDeduplication
) {
return createVariable({ names, value, skipDeduplication })
}
if (!substringVariables) {
return value
}
let resultValue = value
for (const [phrase, names] of phrases) {
if (
!notExtractablePartialPhrases.includes(phrase) &&
names.length >= minRepeatCount &&
typeof value === 'string' &&
value.includes(phrase) &&
phrase.length / value.length > MAX_PHRASE_TO_VALUE_LENGTH_RATIO
) {
resultValue = createVariable({
phrase,
names,
value: resultValue,
skipDeduplication,
})
}
}
return resultValue
},
2,
)
for (const replacements of variableValues.values()) {
// eslint-disable-next-line @typescript-eslint/no-loop-func
replacements.forEach(({ partial, placeholder, replacement }, index) => {
if (partial) {
const wrappingToken = replacements.length - 1 === index ? '`' : '"'
const jsonStringRegExp = new RegExp(
`: (?<!\\\\)".*?${placeholder}.*?(?<!\\\\)"`,
'g',
)
stringifiedResult = stringifiedResult.replace(
jsonStringRegExp,
(match) =>
`${match.slice(0, 2)}${wrappingToken}${match
// cut out the `: "` from beginning and `"` from end:
.slice(3, -1)
.replaceAll(placeholder, replacement)}${wrappingToken}`,
)
} else {
stringifiedResult = stringifiedResult.replaceAll(
`"${placeholder}"`,
replacement,
)
}
})
}
const valueAsStringToVariableNameArr: [string, string][] = sortBy(
flatten([...variableValues.values()])
.filter(({ skipDeduplication }) => !skipDeduplication)
.map(({ valueAsString, variableName }) => [
valueAsString,
variableName,
]),
[([valueAsString]) => -valueAsString.length],
)
const valueAsStringToVariableName = new Map(
valueAsStringToVariableNameArr,
)
allValueAsStringToVariableName.push(...valueAsStringToVariableNameArr)
replacedVariables.forEach((value, key) => {
allReplacedVariables.push([key, value])
})
const { clientName, operationName, feature, action } = event
let fixtureFnName = camelCase(
`get ${clientName} ${operationName ?? feature} fixture`,
)
const existingFixtureFnName =
existingStringifiedFixturesToNames.get(stringifiedResult)
if (existingFixtureFnName) {
let suffixNumber = 2
const fixtureFnNameBase = fixtureFnName
while (existingFixtureNames.has(fixtureFnName)) {
fixtureFnName = `${fixtureFnNameBase}${suffixNumber++}`
}
existingFixtureNames.add(fixtureFnName)
return {
...event,
fixtureFnName,
reuseFixture: existingFixtureFnName,
valueAsStringToVariableName,
replacedVariables,
stringifiedResult,
}
}
if (existingFixtureNames.has(fixtureFnName)) {
fixtureFnName = camelCase(
`get ${clientName} ${operationName ?? feature} ${action} fixture`,
)
}
let suffixNumber = 2
const fixtureFnNameBase = fixtureFnName
while (existingFixtureNames.has(fixtureFnName)) {
fixtureFnName = `${fixtureFnNameBase}${suffixNumber++}`
}
existingFixtureNames.add(fixtureFnName)
existingStringifiedFixturesToNames.set(stringifiedResult, fixtureFnName)
return {
...event,
fixtureFnName,
reuseFixture: undefined,
valueAsStringToVariableName,
replacedVariables,
stringifiedResult,
}
})
const allValueAsStringToVariableNameMap: Map<string, string> = new Map(
sortBy(allValueAsStringToVariableName, [
([valueAsString]) => -valueAsString.length,
]),
)
const fixtureFnToVariables: Map<string, string> = new Map()
const recordingWithFixtures: RecordingElementWithFixtureData[] =
recordingWithFixtureMeta.map((event) => {
if (event.type === 'marker') return event
const {
clientName,
operationName,
feature,
action,
replacedVariables = new Map<string, Replacements>(),
fixtureFnName,
reuseFixture,
stringifiedResult,
} = event
const printedVariables = new Set()
const printVariable = (
[variableName, keyValuePairs]: [
string,
{ key: string; value: unknown }[],
],
style = 'parameter',
): string => {
if (printedVariables.has(variableName)) return ''
printedVariables.add(variableName)
const assignmentToken = style === 'parameter' ? ' =' : ':'
const aka = uniq(keyValuePairs.map(({ key }) => key).filter(Boolean))
const akaString =
aka.length > 1 ? ` /** known as: ${aka.join(', ')} */\n` : ''
const { value } = keyValuePairs[0] ?? {}
let stringifiedValue = JSON.stringify(value)
const needPrintingFirst = []
const valueIsIdLike = /^[\d_-]+$/.test(String(value))
if (typeof value === 'string' && !valueIsIdLike) {
let replacedValue = value
const replacements: Map<
{ placeholder: string; variableName: string },
string
> = new Map()
let placeholderIndex = 1
for (const [
referencedValueAsString,
referencedVariableName,
] of allValueAsStringToVariableNameMap) {
// skip self:
if (
referencedVariableName === variableName ||
referencedValueAsString.length < MAX_REFERENCED_VALUE_LENGTH
) {
// eslint-disable-next-line no-continue
continue
}
let referencedIndex
while (
// eslint-disable-next-line no-cond-assign
(referencedIndex = indexOfWord(
replacedValue,
referencedValueAsString,
)) >= 0
) {
const placeholder = `_____PLACEHOLDER_${placeholderIndex++}_____`
replacedValue =
replacedValue.slice(0, referencedIndex) +
placeholder +
replacedValue.slice(
referencedIndex + referencedValueAsString.length,
)
replacements.set(
{ placeholder, variableName: referencedVariableName },
`\${${referencedVariableName}}`,
)
}
}
if (replacements.size > 0) {
stringifiedValue = JSON.stringify(replacedValue)
for (const [
{ placeholder, variableName: referencedVariableName },
replacementString,
] of replacements) {
stringifiedValue = stringifiedValue.replaceAll(
placeholder,
replacementString,
)
needPrintingFirst.push(referencedVariableName)
}
// replace surrounding quotes with backticks:
stringifiedValue = `\`${stringifiedValue.slice(1, -1)}\``
}
}
return `${needPrintingFirst
.map((referencedVariableName) =>
printVariable(
allReplacedVariables.find(
([name]) => referencedVariableName === name,
)!,
style,
),
)
.join(
'',
)}${akaString} ${variableName}${assignmentToken} ${stringifiedValue},\n`
}
const variablesAsParameter = [...replacedVariables]
.map((pair) => printVariable(pair, 'parameter'))
.join('')
const initialVariables = reuseFixture
? fixtureFnToVariables.get(reuseFixture)
: undefined
const variables = reuseFixture
? initialVariables === variablesAsParameter
? ''
: [...replacedVariables]
.map((pair) => printVariable(pair, 'object'))
.join('')
: variablesAsParameter
if (!initialVariables) {
fixtureFnToVariables.set(fixtureFnName, variablesAsParameter)
}
const fixtureJsDoc = `/**
* @description Fixture for operation ${clientName}/${operationName}${
feature ? ` (${feature})` : ''
}, captured when ${action}.
*/
`
let fixtureFnString = ''
const fixtureCallString = reuseFixture
? `${reuseFixture}({${variables === '' ? '' : `\n${variables}`}})`
: `${fixtureFnName}()`
if (!reuseFixture) {
const fixtureFnHeader =
(replacedVariables?.size ?? 0) === 0
? `const ${fixtureFnName} = () => `
: `const ${fixtureFnName} = (
{
${variables} } = {},
) => `
fixtureFnString = `${fixtureJsDoc}${fixtureFnHeader}(${stringifiedResult});`
}
const result: RecordingPointWithFixtureData = {
...(event as RecordingPoint),
fixtureFnString,
fixtureFnName,
fixtureCallString,
}
return result
})
const events = recordingWithFixtures.filter(
(event): event is RecordingPointWithFixtureData => event.type !== 'marker',
)
const fixturesString = events
.map((event) => event.fixtureFnString)
.filter(Boolean)
.join('\n')
const grouped = groupBy(events, ({ clientName, operationName, feature }) =>
camelCase(`${clientName} ${operationName ?? feature} Interceptor`),
)
const eventToInterceptorMetaMap: Map<
RecordingPoint,
{ interceptorVariableName: string; hasMultipleVariations: boolean }
> = new Map()
const interceptorsString = Object.entries(grouped)
.map(([interceptorVariableName, events]) => {
const variableVariations = Object.keys(
groupBy(events, ({ variables }) => JSON.stringify(variables)),
)
const hasMultipleVariations = variableVariations.length > 1
events.forEach((event) =>
eventToInterceptorMetaMap.set(event, {
interceptorVariableName,
hasMultipleVariations,
}),
)
const [firstEvent] = events
if (!firstEvent) return ''
return `
const ${interceptorVariableName} = ${referenceName}.intercept({
clientName: ${JSON.stringify(firstEvent.clientName)},
${
firstEvent.operationName == null
? `// this operation is unnamed, please update the part of the code that references these variables: ${Object.keys(
firstEvent.variables,
).join(', ')} `
: `operationName: ${JSON.stringify(firstEvent.operationName)}`
},${
firstEvent.feature
? `\n // feature: ${JSON.stringify(firstEvent.feature)},`
: ''
}
// (${
hasMultipleVariations
? 'multiple sets of variables captured, matchers added to the individual mock statements'
: 'single set of variables captured - specifier likely not required'
})
// variables: ${variableVariations.join('\n // variables: ')},
});
`
})
.join('\n')
const callsString = recordingWithFixtures
.map((event) => {
const timePassed = (event.timeDelta / ONE_SECOND_MS).toFixed(2)
if (event.type === 'marker') {
return `\n// action: ${event.action} (${timePassed}s)`
}
const { variables, type, fixtureCallString } = event
const meta = eventToInterceptorMetaMap.get(event)
if (!meta) return ''
const { interceptorVariableName, hasMultipleVariations } = meta
const matcherArgument = `{variables: ${JSON.stringify(variables)}}`
return type === 'push'
? `
// subscription update after ${timePassed}s
${interceptorVariableName}.fireSubscriptionUpdate(${fixtureCallString}${
hasMultipleVariations
? `, ${matcherArgument});`
: `); // ${matcherArgument}`
}
`
: `
// backend response after ${timePassed}s
${interceptorVariableName}.mockResultOnce(${fixtureCallString}${
hasMultipleVariations
? `, ${matcherArgument});`
: `); // ${matcherArgument}`
}
`
})
.join('\n')
return `
/** FIXTURES **/
${fixturesString}
/** INTERCEPTOR DECLARATIONS **/
${interceptorsString}
/** MOCK SETUP AND PUSH IN ORDER OF EVENTS **/
${callsString}
`
}