UNPKG

@zendesk/laika

Version:

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

400 lines (398 loc) 21 kB
"use strict"; /* 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