@zendesk/laika
Version:
Test, mock, intercept and modify Apollo Client's operations — in both browser and unit tests!
400 lines (398 loc) • 21 kB
JavaScript
;
/* eslint-disable @typescript-eslint/no-shadow,@typescript-eslint/triple-slash-reference */
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.generateCode = void 0;
/// <reference types="typescript/lib/lib.es2021" />
const camelCase_1 = __importDefault(require("lodash/camelCase"));
const flatten_1 = __importDefault(require("lodash/flatten"));
const groupBy_1 = __importDefault(require("lodash/groupBy"));
const sortBy_1 = __importDefault(require("lodash/sortBy"));
const uniq_1 = __importDefault(require("lodash/uniq"));
const codeGeneratorUtils_1 = require("./codeGeneratorUtils");
const MAX_VALUE_LENGTH = 100;
const MAX_PHRASE_TO_VALUE_LENGTH_RATIO = 0.3;
const MAX_REFERENCED_VALUE_LENGTH = 5;
const ONE_SECOND_MS = 1000;
// 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!
const generateCode = ({ recording: inputRecording, referenceName, }, eventFilter = () => 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 = new Map();
const getTraverser = (event, skipPathRootsCount = 0) => ({ path, value, key, }) => {
var _a;
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 = (_a = phrasesList.get(valueAsString)) !== null && _a !== void 0 ? _a : [];
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:
(0, codeGeneratorUtils_1.forEachDeep)({ value: event.variables }, getTraverser(event));
// deeply traverse this result:
(0, codeGeneratorUtils_1.forEachDeep)({ value: event.result }, getTraverser(event, 2));
});
const existingFixtureNames = new Set();
const existingStringifiedFixturesToNames = new Map();
// sort this map from longest value to shortest to get most accurate results
const phrases = new Map((0, sortBy_1.default)([...phrasesList], [([value]) => -value.length]));
const allValueAsStringToVariableName = [];
const allReplacedVariables = [];
// second time we iterate we'll have collected data from all recorded events (and thus repetitions)
const recordingWithFixtureMeta = recording.map((event) => {
if (event.type === 'marker')
return event;
const replacedVariables = new Map();
const variableValues = new Map();
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) => {
var _a;
if (!key ||
nonVariableKeys.includes(key) ||
(typeof value !== 'string' &&
typeof value !== 'number' &&
typeof value !== 'boolean')) {
return value;
}
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, }) => {
var _a, _b, _c;
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 } = {}] = (0, sortBy_1.default)(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 = `${(0, codeGeneratorUtils_1.startsWithNumber)(name) ? '_' : ''}${(0, camelCase_1.default)(name)}`;
let variableName = variableNameBase;
let suffixNumber = 2;
while (replacedVariables.has(variableName) &&
(skipDeduplication ||
((_b = (_a = replacedVariables.get(variableName)) === null || _a === void 0 ? void 0 : _a[0]) === null || _b === void 0 ? void 0 : _b.value) !== value)) {
variableName = `${variableNameBase}${suffixNumber++}`;
}
replacedVariables.set(variableName, names);
const placeholder = `______${phrase ? 'PARTIAL' : 'VARIABLE'}_${variableName}_${duplicateSuffix++}______`;
const replacements = (_c = variableValues.get(valueAsString)) !== null && _c !== void 0 ? _c : [];
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 !== null && phrase !== void 0 ? phrase : valueAsString,
});
variableValues.set(valueAsString, replacements);
return phrase
? valueAsString.replaceAll(phrase, placeholder)
: placeholder;
};
const names = skipDeduplication
? [{ key, value }]
: (_a = phrases.get(valueAsString)) !== null && _a !== void 0 ? _a : [];
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 = (0, sortBy_1.default)((0, flatten_1.default)([...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 = (0, camelCase_1.default)(`get ${clientName} ${operationName !== null && operationName !== void 0 ? 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 Object.assign(Object.assign({}, event), { fixtureFnName, reuseFixture: existingFixtureFnName, valueAsStringToVariableName,
replacedVariables,
stringifiedResult });
}
if (existingFixtureNames.has(fixtureFnName)) {
fixtureFnName = (0, camelCase_1.default)(`get ${clientName} ${operationName !== null && operationName !== void 0 ? 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 Object.assign(Object.assign({}, event), { fixtureFnName, reuseFixture: undefined, valueAsStringToVariableName,
replacedVariables,
stringifiedResult });
});
const allValueAsStringToVariableNameMap = new Map((0, sortBy_1.default)(allValueAsStringToVariableName, [
([valueAsString]) => -valueAsString.length,
]));
const fixtureFnToVariables = new Map();
const recordingWithFixtures = recordingWithFixtureMeta.map((event) => {
var _a;
if (event.type === 'marker')
return event;
const { clientName, operationName, feature, action, replacedVariables = new Map(), fixtureFnName, reuseFixture, stringifiedResult, } = event;
const printedVariables = new Set();
const printVariable = ([variableName, keyValuePairs], style = 'parameter') => {
var _a;
if (printedVariables.has(variableName))
return '';
printedVariables.add(variableName);
const assignmentToken = style === 'parameter' ? ' =' : ':';
const aka = (0, uniq_1.default)(keyValuePairs.map(({ key }) => key).filter(Boolean));
const akaString = aka.length > 1 ? ` /** known as: ${aka.join(', ')} */\n` : '';
const { value } = (_a = keyValuePairs[0]) !== null && _a !== void 0 ? _a : {};
let stringifiedValue = JSON.stringify(value);
const needPrintingFirst = [];
const valueIsIdLike = /^[\d_-]+$/.test(String(value));
if (typeof value === 'string' && !valueIsIdLike) {
let replacedValue = value;
const replacements = 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 = (0, codeGeneratorUtils_1.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 = ((_a = replacedVariables === null || replacedVariables === void 0 ? void 0 : replacedVariables.size) !== null && _a !== void 0 ? _a : 0) === 0
? `const ${fixtureFnName} = () => `
: `const ${fixtureFnName} = (
{
${variables} } = {},
) => `;
fixtureFnString = `${fixtureJsDoc}${fixtureFnHeader}(${stringifiedResult});`;
}
const result = Object.assign(Object.assign({}, event), { fixtureFnString,
fixtureFnName,
fixtureCallString });
return result;
});
const events = recordingWithFixtures.filter((event) => event.type !== 'marker');
const fixturesString = events
.map((event) => event.fixtureFnString)
.filter(Boolean)
.join('\n');
const grouped = (0, groupBy_1.default)(events, ({ clientName, operationName, feature }) => (0, camelCase_1.default)(`${clientName} ${operationName !== null && operationName !== void 0 ? operationName : feature} Interceptor`));
const eventToInterceptorMetaMap = new Map();
const interceptorsString = Object.entries(grouped)
.map(([interceptorVariableName, events]) => {
const variableVariations = Object.keys((0, groupBy_1.default)(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}
`;
};
exports.generateCode = generateCode;
//# sourceMappingURL=codeGenerator.js.map