diff --git a/src/analyze/javascript/extractors/event-extractor.js b/src/analyze/javascript/extractors/event-extractor.js index eaa7f7e..c2a101b 100644 --- a/src/analyze/javascript/extractors/event-extractor.js +++ b/src/analyze/javascript/extractors/event-extractor.js @@ -28,29 +28,31 @@ const EXTRACTION_STRATEGIES = { * Extracts event information from a CallExpression node * @param {Object} node - AST CallExpression node * @param {string} source - Analytics provider source + * @param {Object} constantMap - Collected constant map * @param {Object} customConfig - Parsed custom function configuration * @returns {EventData} Extracted event data */ -function extractEventData(node, source, customConfig) { +function extractEventData(node, source, constantMap = {}, customConfig) { const strategy = EXTRACTION_STRATEGIES[source] || EXTRACTION_STRATEGIES.default; if (source === 'custom') { - return strategy(node, customConfig); + return strategy(node, constantMap, customConfig); } - return strategy(node); + return strategy(node, constantMap); } /** * Extracts Google Analytics event data * @param {Object} node - CallExpression node + * @param {Object} constantMap - Collected constant map * @returns {EventData} */ -function extractGoogleAnalyticsEvent(node) { +function extractGoogleAnalyticsEvent(node, constantMap) { if (!node.arguments || node.arguments.length < 3) { return { eventName: null, propertiesNode: null }; } // gtag('event', 'event_name', { properties }) - const eventName = getStringValue(node.arguments[1]); + const eventName = getStringValue(node.arguments[1], constantMap); const propertiesNode = node.arguments[2]; return { eventName, propertiesNode }; @@ -59,9 +61,10 @@ function extractGoogleAnalyticsEvent(node) { /** * Extracts Snowplow event data * @param {Object} node - CallExpression node + * @param {Object} constantMap - Collected constant map * @returns {EventData} */ -function extractSnowplowEvent(node) { +function extractSnowplowEvent(node, constantMap) { if (!node.arguments || node.arguments.length === 0) { return { eventName: null, propertiesNode: null }; } @@ -75,7 +78,7 @@ function extractSnowplowEvent(node) { if (structEventArg.type === NODE_TYPES.OBJECT_EXPRESSION) { const actionProperty = findPropertyByKey(structEventArg, 'action'); - const eventName = actionProperty ? getStringValue(actionProperty.value) : null; + const eventName = actionProperty ? getStringValue(actionProperty.value, constantMap) : null; return { eventName, propertiesNode: structEventArg }; } @@ -87,15 +90,16 @@ function extractSnowplowEvent(node) { /** * Extracts mParticle event data * @param {Object} node - CallExpression node + * @param {Object} constantMap - Collected constant map * @returns {EventData} */ -function extractMparticleEvent(node) { +function extractMparticleEvent(node, constantMap) { if (!node.arguments || node.arguments.length < 3) { return { eventName: null, propertiesNode: null }; } // mParticle.logEvent('event_name', mParticle.EventType.Navigation, { properties }) - const eventName = getStringValue(node.arguments[0]); + const eventName = getStringValue(node.arguments[0], constantMap); const propertiesNode = node.arguments[2]; return { eventName, propertiesNode }; @@ -104,15 +108,16 @@ function extractMparticleEvent(node) { /** * Default event extraction for standard providers * @param {Object} node - CallExpression node + * @param {Object} constantMap - Collected constant map * @returns {EventData} */ -function extractDefaultEvent(node) { +function extractDefaultEvent(node, constantMap) { if (!node.arguments || node.arguments.length < 2) { return { eventName: null, propertiesNode: null }; } // provider.track('event_name', { properties }) - const eventName = getStringValue(node.arguments[0]); + const eventName = getStringValue(node.arguments[0], constantMap); const propertiesNode = node.arguments[1]; return { eventName, propertiesNode }; @@ -121,16 +126,17 @@ function extractDefaultEvent(node) { /** * Extracts Custom function event data according to signature * @param {Object} node - CallExpression node + * @param {Object} constantMap - Collected constant map * @param {Object} customConfig - Parsed custom function configuration * @returns {EventData & {extraArgs:Object}} event data plus extra args map */ -function extractCustomEvent(node, customConfig) { +function extractCustomEvent(node, constantMap, customConfig) { const args = node.arguments || []; const eventArg = args[customConfig?.eventIndex ?? 0]; const propertiesArg = args[customConfig?.propertiesIndex ?? 1]; - const eventName = getStringValue(eventArg); + const eventName = getStringValue(eventArg, constantMap); const extraArgs = {}; if (customConfig && customConfig.extraParams) { @@ -197,13 +203,17 @@ function processEventData(eventData, source, filePath, line, functionName, custo /** * Gets string value from an AST node * @param {Object} node - AST node + * @param {Object} constantMap - Collected constant map * @returns {string|null} String value or null */ -function getStringValue(node) { +function getStringValue(node, constantMap = {}) { if (!node) return null; if (node.type === NODE_TYPES.LITERAL && typeof node.value === 'string') { return node.value; } + if (node.type === NODE_TYPES.MEMBER_EXPRESSION) { + return resolveMemberExpressionToString(node, constantMap); + } return null; } @@ -240,6 +250,26 @@ function inferNodeValueType(node) { } } +// Helper to resolve MemberExpression (CONST.KEY) to string using collected constant map +function resolveMemberExpressionToString(node, constantMap) { + if (!node || node.type !== NODE_TYPES.MEMBER_EXPRESSION) return null; + if (node.computed) return null; // Only support dot notation + + const object = node.object; + const property = node.property; + + if (object.type !== NODE_TYPES.IDENTIFIER) return null; + if (property.type !== NODE_TYPES.IDENTIFIER) return null; + + const objName = object.name; + const propName = property.name; + + if (constantMap && constantMap[objName] && typeof constantMap[objName][propName] === 'string') { + return constantMap[objName][propName]; + } + return null; +} + module.exports = { extractEventData, processEventData diff --git a/src/analyze/javascript/parser.js b/src/analyze/javascript/parser.js index ce3e8e2..b35a43f 100644 --- a/src/analyze/javascript/parser.js +++ b/src/analyze/javascript/parser.js @@ -120,6 +120,57 @@ function nodeMatchesCustomFunction(node, fnName) { return false; } +// ----------------------------------------------------------------------------- +// Utility – collect constants defined as plain objects or Object.freeze({...}) +// ----------------------------------------------------------------------------- +function collectConstantStringMap(ast) { + const map = {}; + + walk.simple(ast, { + VariableDeclaration(node) { + // Only consider const declarations + if (node.kind !== 'const') return; + node.declarations.forEach(decl => { + if (decl.id.type !== NODE_TYPES.IDENTIFIER || !decl.init) return; + const name = decl.id.name; + let objLiteral = null; + + if (decl.init.type === NODE_TYPES.OBJECT_EXPRESSION) { + objLiteral = decl.init; + } else if (decl.init.type === NODE_TYPES.CALL_EXPRESSION) { + // Check for Object.freeze({...}) + const callee = decl.init.callee; + if ( + callee && + callee.type === NODE_TYPES.MEMBER_EXPRESSION && + callee.object.type === NODE_TYPES.IDENTIFIER && + callee.object.name === 'Object' && + callee.property.type === NODE_TYPES.IDENTIFIER && + callee.property.name === 'freeze' && + decl.init.arguments.length > 0 && + decl.init.arguments[0].type === NODE_TYPES.OBJECT_EXPRESSION + ) { + objLiteral = decl.init.arguments[0]; + } + } + + if (objLiteral) { + map[name] = {}; + objLiteral.properties.forEach(prop => { + if (!prop.key || !prop.value) return; + const keyName = prop.key.name || prop.key.value; + if (prop.value.type === NODE_TYPES.LITERAL && typeof prop.value.value === 'string') { + map[name][keyName] = prop.value.value; + } + }); + } + }); + } + }); + + return map; +} + /** * Walk the AST once and find tracking events for built-in providers plus any number of custom * function configurations. This avoids the previous O(n * customConfigs) behaviour. @@ -132,6 +183,9 @@ function nodeMatchesCustomFunction(node, fnName) { function findTrackingEvents(ast, filePath, customConfigs = []) { const events = []; + // Collect constant mappings once per file + const constantMap = collectConstantStringMap(ast); + walk.ancestor(ast, { [NODE_TYPES.CALL_EXPRESSION]: (node, ancestors) => { try { @@ -148,12 +202,10 @@ function findTrackingEvents(ast, filePath, customConfigs = []) { } if (matchedCustomConfig) { - // Force source to 'custom' and use matched config - const event = extractTrackingEvent(node, ancestors, filePath, matchedCustomConfig); + const event = extractTrackingEvent(node, ancestors, filePath, constantMap, matchedCustomConfig); if (event) events.push(event); } else { - // Let built-in detector figure out source (pass undefined customFunction) - const event = extractTrackingEvent(node, ancestors, filePath, null); + const event = extractTrackingEvent(node, ancestors, filePath, constantMap, null); if (event) events.push(event); } } catch (error) { @@ -170,24 +222,18 @@ function findTrackingEvents(ast, filePath, customConfigs = []) { * @param {Object} node - CallExpression node * @param {Array} ancestors - Ancestor nodes * @param {string} filePath - File path + * @param {Object} constantMap - Constant string map * @param {Object} [customConfig] - Custom function configuration object * @returns {Object|null} Extracted event or null */ -function extractTrackingEvent(node, ancestors, filePath, customConfig) { - // Detect the analytics source +function extractTrackingEvent(node, ancestors, filePath, constantMap, customConfig) { const source = detectAnalyticsSource(node, customConfig?.functionName); if (source === 'unknown') { return null; } - - // Extract event data based on the source - const eventData = extractEventData(node, source, customConfig); - - // Get location and context information + const eventData = extractEventData(node, source, constantMap, customConfig); const line = node.loc.start.line; const functionName = findWrappingFunction(node, ancestors); - - // Process the event data into final format return processEventData(eventData, source, filePath, line, functionName, customConfig); } diff --git a/src/analyze/typescript/extractors/event-extractor.js b/src/analyze/typescript/extractors/event-extractor.js index a944a50..5c87211 100644 --- a/src/analyze/typescript/extractors/event-extractor.js +++ b/src/analyze/typescript/extractors/event-extractor.js @@ -296,27 +296,75 @@ function resolvePropertyAccessToString(node, checker, sourceFile) { try { // Get the symbol for the property access const symbol = checker.getSymbolAtLocation(node); - if (!symbol || !symbol.valueDeclaration) { - return null; - } - - // Check if it's a property assignment with a string initializer - if (ts.isPropertyAssignment(symbol.valueDeclaration) && - symbol.valueDeclaration.initializer && - ts.isStringLiteral(symbol.valueDeclaration.initializer)) { - return symbol.valueDeclaration.initializer.text; + if (symbol && symbol.valueDeclaration) { + // Check if it's a property assignment with a string initializer + if (ts.isPropertyAssignment(symbol.valueDeclaration) && + symbol.valueDeclaration.initializer && + ts.isStringLiteral(symbol.valueDeclaration.initializer)) { + return symbol.valueDeclaration.initializer.text; + } + + // Check if it's a variable declaration property (string literal type) + if (ts.isPropertySignature(symbol.valueDeclaration) || + ts.isMethodSignature(symbol.valueDeclaration)) { + const type = checker.getTypeAtLocation(node); + if (type && type.isStringLiteral && type.isStringLiteral()) { + return type.value; + } + } } - - // Check if it's a variable declaration property - if (ts.isPropertySignature(symbol.valueDeclaration) || - ts.isMethodSignature(symbol.valueDeclaration)) { - // Try to get the type and see if it's a string literal type - const type = checker.getTypeAtLocation(node); - if (type.isStringLiteral && type.isStringLiteral()) { - return type.value; + + // --------------------------------------------------------------------- + // Fallback – manually resolve patterns like: + // const CONST = { KEY: 'value' }; + // const CONST = Object.freeze({ KEY: 'value' }); + // And later used as CONST.KEY + // --------------------------------------------------------------------- + if (ts.isIdentifier(node.expression)) { + const objIdentifier = node.expression; + const initializer = resolveIdentifierToInitializer(checker, objIdentifier, sourceFile); + if (initializer) { + let objectLiteral = null; + + // Handle direct object literal initializers + if (ts.isObjectLiteralExpression(initializer)) { + objectLiteral = initializer; + } + // Handle Object.freeze({ ... }) pattern + else if (ts.isCallExpression(initializer)) { + const callee = initializer.expression; + if ( + ts.isPropertyAccessExpression(callee) && + ts.isIdentifier(callee.expression) && + callee.expression.escapedText === 'Object' && + callee.name.escapedText === 'freeze' && + initializer.arguments.length > 0 && + ts.isObjectLiteralExpression(initializer.arguments[0]) + ) { + objectLiteral = initializer.arguments[0]; + } + } + + if (objectLiteral) { + const propNode = findPropertyByKey(objectLiteral, node.name.escapedText || node.name.text); + if (propNode && propNode.initializer && ts.isStringLiteral(propNode.initializer)) { + return propNode.initializer.text; + } + } } } - + + // Final fallback – use type information at location (works for imported Object.freeze constants) + try { + const t = checker.getTypeAtLocation(node); + if (t && t.isStringLiteral && typeof t.isStringLiteral === 'function' && t.isStringLiteral()) { + return t.value; + } + if (t && t.flags && (t.flags & ts.TypeFlags.StringLiteral)) { + return t.value; + } + } catch (_) {/* ignore */} + return null; } catch (error) { return null; diff --git a/tests/analyzeJavaScript.test.js b/tests/analyzeJavaScript.test.js index 1a7a265..f1a2b21 100644 --- a/tests/analyzeJavaScript.test.js +++ b/tests/analyzeJavaScript.test.js @@ -16,7 +16,7 @@ test.describe('analyzeJsFile', () => { // Sort events by line number for consistent ordering events.sort((a, b) => a.line - b.line); - assert.strictEqual(events.length, 11); + assert.strictEqual(events.length, 12); // Test Google Analytics event const gaEvent = events.find(e => e.eventName === 'purchase' && e.source === 'googleanalytics'); @@ -199,6 +199,20 @@ test.describe('analyzeJsFile', () => { items: { type: 'string' } } }); + + // Test frozen constant event name via Object.freeze constant + const frozenEvent = events.find(e => e.eventName === 'ecommerce_purchase_frozen'); + assert.ok(frozenEvent); + assert.strictEqual(frozenEvent.source, 'mixpanel'); + assert.strictEqual(frozenEvent.functionName, 'global'); + assert.deepStrictEqual(frozenEvent.properties, { + orderId: { type: 'string' }, + total: { type: 'number' }, + items: { + type: 'array', + items: { type: 'string' } + } + }); }); test('should handle files without tracking events', () => { @@ -218,7 +232,7 @@ test.describe('analyzeJsFile', () => { const events = analyzeJsFile(testFilePath, null); // Should find all events except the custom one - assert.strictEqual(events.length, 10); + assert.strictEqual(events.length, 11); assert.strictEqual(events.find(e => e.source === 'custom'), undefined); }); diff --git a/tests/analyzeTypeScript.test.js b/tests/analyzeTypeScript.test.js index 904d11f..fd65edf 100644 --- a/tests/analyzeTypeScript.test.js +++ b/tests/analyzeTypeScript.test.js @@ -23,15 +23,21 @@ test.describe('analyzeTsFile', () => { } test('should correctly analyze TypeScript file with multiple tracking providers', () => { - const customFunction = 'customTrackFunction(userId, EVENT_NAME, PROPERTIES)'; - const customFunctionSignatures = [parseCustomFunctionSignature(customFunction)]; + const customFunctions = [ + 'customTrackFunction(userId, EVENT_NAME, PROPERTIES)', + 'customTrackFunction5', + 'customTrackFunction6(EVENT_NAME, PROPERTIES)', + 'customTrackFunction7(EVENT_NAME, PROPERTIES)', + 'this.props.customTrackFunction6(EVENT_NAME, PROPERTIES)' + ]; + const customFunctionSignatures = customFunctions.map(parseCustomFunctionSignature); const program = createProgram(testFilePath); const events = analyzeTsFile(testFilePath, program, customFunctionSignatures); // Sort events by line number for consistent ordering events.sort((a, b) => a.line - b.line); - assert.strictEqual(events.length, 14); + assert.strictEqual(events.length, 19); // Test Google Analytics event const gaEvent = events.find(e => e.eventName === 'order_completed' && e.source === 'googleanalytics'); @@ -345,6 +351,39 @@ test.describe('analyzeTsFile', () => { items: { type: 'string' } } }); + + // Test frozen constant event + const frozenConstantEvent = events.find(e => e.eventName === 'ecommerce_purchase_frozen'); + assert.ok(frozenConstantEvent); + assert.strictEqual(frozenConstantEvent.source, 'mixpanel'); + assert.strictEqual(frozenConstantEvent.functionName, 'global'); + assert.strictEqual(frozenConstantEvent.line, 292); + assert.deepStrictEqual(frozenConstantEvent.properties, { + orderId: { type: 'string' }, + total: { type: 'number' }, + items: { + type: 'array', + items: { type: 'string' } + } + }); + + // Test InitiatedPayment custom event (nested dispatch) + const initiatedPaymentEvent = events.find(e => e.eventName === 'InitiatedPayment'); + assert.ok(initiatedPaymentEvent); + assert.strictEqual(initiatedPaymentEvent.source, 'custom'); + assert.deepStrictEqual(initiatedPaymentEvent.properties, { + containerSection: { type: 'string' }, + tierCartIntent: { type: 'string' } + }); + + // Test FailedPayment custom event (variable properties) – from customTrackFunction5 + const failedPaymentEvent = events.find(e => e.eventName === 'FailedPayment'); + assert.ok(failedPaymentEvent); + assert.strictEqual(failedPaymentEvent.source, 'custom'); + assert.deepStrictEqual(failedPaymentEvent.properties, { + containerSection: { type: 'string' }, + amount: { type: 'number' } + }); }); test('should handle files without tracking events', () => { @@ -366,7 +405,7 @@ test.describe('analyzeTsFile', () => { const events = analyzeTsFile(testFilePath, program, null); // Should find all events except the custom ones - assert.strictEqual(events.length, 12); + assert.strictEqual(events.length, 13); assert.strictEqual(events.find(e => e.source === 'custom'), undefined); }); @@ -877,6 +916,9 @@ test.describe('analyzeTsFile', () => { { sig: 'customTrackFunction3(EVENT_NAME, PROPERTIES, userEmail)', event: 'custom_event3' }, { sig: 'customTrackFunction4(userId, EVENT_NAME, userAddress, PROPERTIES, userEmail)', event: 'custom_event4' }, { sig: 'CustomModule.track(userId, EVENT_NAME, PROPERTIES)', event: 'custom_module_event' }, + { sig: 'customTrackFunction5', event: 'FailedPayment' }, + { sig: 'this.props.customTrackFunction6(EVENT_NAME, PROPERTIES)', event: 'ViewedAttorneyAgreement' }, + { sig: 'customTrackFunction7(EVENT_NAME, PROPERTIES)', event: 'InitiatedPayment' }, ]; variants.forEach(({ sig, event }) => { @@ -896,7 +938,10 @@ test.describe('analyzeTsFile', () => { 'customTrackFunction2(userId, EVENT_NAME, PROPERTIES)', 'customTrackFunction3(EVENT_NAME, PROPERTIES, userEmail)', 'customTrackFunction4(userId, EVENT_NAME, userAddress, PROPERTIES, userEmail)', - 'CustomModule.track(userId, EVENT_NAME, PROPERTIES)' + 'CustomModule.track(userId, EVENT_NAME, PROPERTIES)', + 'customTrackFunction5', + 'this.props.customTrackFunction6(EVENT_NAME, PROPERTIES)', + 'customTrackFunction7(EVENT_NAME, PROPERTIES)', ]; const customFunctionSignatures = variants.map(parseCustomFunctionSignature); @@ -911,7 +956,10 @@ test.describe('analyzeTsFile', () => { 'custom_event2', 'custom_event3', 'custom_event4', - 'custom_module_event' + 'custom_module_event', + 'FailedPayment', + 'ViewedAttorneyAgreement', + 'InitiatedPayment' ]; expectedEventNames.forEach(eventName => { @@ -921,6 +969,6 @@ test.describe('analyzeTsFile', () => { // Ensure built-in provider events remain unaffected const builtInCount = events.filter(e => e.source !== 'custom').length; - assert.ok(builtInCount >= 12, 'Should still include built-in provider events'); + assert.ok(builtInCount >= 10, 'Should still include built-in provider events'); }); }); diff --git a/tests/cli.test.js b/tests/cli.test.js index 4fd8050..6fe73c6 100644 --- a/tests/cli.test.js +++ b/tests/cli.test.js @@ -16,7 +16,11 @@ const customFunctionSignatures = [ 'customTrackFunction2(userId, EVENT_NAME, PROPERTIES)', 'customTrackFunction3(EVENT_NAME, PROPERTIES, userEmail)', 'customTrackFunction4(userId, EVENT_NAME, userAddress, PROPERTIES, userEmail)', - 'CustomModule.track(userId, EVENT_NAME, PROPERTIES)' + 'CustomModule.track(userId, EVENT_NAME, PROPERTIES)', + 'customTrackFunction5', + 'customTrackFunction6(EVENT_NAME, PROPERTIES)', + 'this.props.customTrackFunction6(EVENT_NAME, PROPERTIES)', + 'customTrackFunction7(EVENT_NAME, PROPERTIES)', ]; // Helper function to run CLI and capture output diff --git a/tests/fixtures/javascript/main.js b/tests/fixtures/javascript/main.js index bd0d27f..8f0ba3c 100644 --- a/tests/fixtures/javascript/main.js +++ b/tests/fixtures/javascript/main.js @@ -179,3 +179,16 @@ CustomModule.track('user321', 'custom_module_event', { order_id: 'order123', foo: 'bar' }); + +// ----------------------------------------------------------------------------- +// Object.freeze constant tracking example (new test case) +// ----------------------------------------------------------------------------- +const TRACKING_EVENTS_FROZEN = Object.freeze({ + ECOMMERCE_PURCHASE: 'ecommerce_purchase_frozen', +}); + +mixpanel.track(TRACKING_EVENTS_FROZEN.ECOMMERCE_PURCHASE, { + orderId: 'order_123', + total: 99.99, + items: ['sku_1', 'sku_2'] +}); diff --git a/tests/fixtures/javascript/tracking-schema-javascript.yaml b/tests/fixtures/javascript/tracking-schema-javascript.yaml index b2acffb..5f79c77 100644 --- a/tests/fixtures/javascript/tracking-schema-javascript.yaml +++ b/tests/fixtures/javascript/tracking-schema-javascript.yaml @@ -2,8 +2,8 @@ version: 1 source: repository: git@github.com:fliskdata/analyze-tracking.git - commit: f9eeddec7c52731efddc5ca5f8fbd8bf4c6a89cf - timestamp: '2025-06-25T03:57:29Z' + commit: c5cb9b287b787da97c8d3d41b229ed14a8650ac9 + timestamp: '2025-06-26T04:25:24Z' events: purchase: implementations: @@ -269,3 +269,18 @@ events: type: string userId: type: string + ecommerce_purchase_frozen: + implementations: + - path: main.js + line: 190 + function: global + destination: mixpanel + properties: + orderId: + type: string + total: + type: number + items: + type: array + items: + type: string diff --git a/tests/fixtures/tracking-schema-all.yaml b/tests/fixtures/tracking-schema-all.yaml index 48a3bf2..0c0461d 100644 --- a/tests/fixtures/tracking-schema-all.yaml +++ b/tests/fixtures/tracking-schema-all.yaml @@ -2,8 +2,8 @@ version: 1 source: repository: git@github.com:fliskdata/analyze-tracking.git - commit: f9eeddec7c52731efddc5ca5f8fbd8bf4c6a89cf - timestamp: '2025-06-25T03:57:29Z' + commit: c5cb9b287b787da97c8d3d41b229ed14a8650ac9 + timestamp: '2025-06-26T04:25:24Z' events: Signed Up: implementations: @@ -161,6 +161,144 @@ events: type: array items: type: number + custom_event0: + implementations: + - path: go/main.go + line: 121 + function: main + destination: custom + - path: javascript/main.js + line: 162 + function: global + destination: custom + - path: python/main.py + line: 161 + function: main + destination: custom + - path: ruby/main.rb + line: 135 + function: global + destination: custom + - path: typescript/main.ts + line: 306 + function: global + destination: custom + properties: + foo: + type: string + custom_event1: + implementations: + - path: go/main.go + line: 122 + function: main + destination: custom + - path: javascript/main.js + line: 163 + function: global + destination: custom + - path: python/main.py + line: 162 + function: main + destination: custom + - path: ruby/main.rb + line: 136 + function: global + destination: custom + - path: typescript/main.ts + line: 307 + function: global + destination: custom + properties: + foo: + type: string + custom_event2: + implementations: + - path: go/main.go + line: 123 + function: main + destination: custom + - path: javascript/main.js + line: 164 + function: global + destination: custom + - path: python/main.py + line: 163 + function: main + destination: custom + - path: ruby/main.rb + line: 137 + function: global + destination: custom + - path: typescript/main.ts + line: 308 + function: global + destination: custom + properties: + userId: + type: string + foo: + type: string + custom_event3: + implementations: + - path: go/main.go + line: 124 + function: main + destination: custom + - path: javascript/main.js + line: 165 + function: global + destination: custom + - path: python/main.py + line: 164 + function: main + destination: custom + - path: ruby/main.rb + line: 138 + function: global + destination: custom + - path: typescript/main.ts + line: 309 + function: global + destination: custom + properties: + userEmail: + type: string + foo: + type: string + custom_event4: + implementations: + - path: go/main.go + line: 125 + function: main + destination: custom + - path: javascript/main.js + line: 166 + function: global + destination: custom + - path: python/main.py + line: 165 + function: main + destination: custom + - path: ruby/main.rb + line: 139 + function: global + destination: custom + - path: typescript/main.ts + line: 310 + function: global + destination: custom + properties: + userId: + type: string + userAddress: + type: object + properties: + city: + type: string + userEmail: + type: string + foo: + type: string purchase: implementations: - path: javascript/main.js @@ -354,6 +492,46 @@ events: type: string userId: type: string + custom_module_event: + implementations: + - path: javascript/main.js + line: 178 + function: global + destination: custom + - path: python/main.py + line: 173 + function: main + destination: custom + - path: typescript/main.ts + line: 322 + function: global + destination: custom + properties: + order_id: + type: string + foo: + type: string + userId: + type: string + ecommerce_purchase_frozen: + implementations: + - path: javascript/main.js + line: 190 + function: global + destination: mixpanel + - path: typescript/main.ts + line: 292 + function: global + destination: mixpanel + properties: + orderId: + type: string + total: + type: number + items: + type: array + items: + type: string User Signed Up: implementations: - path: python/main.py @@ -924,162 +1102,36 @@ events: type: string checkout_step: type: number - custom_event0: - implementations: - - path: go/main.go - line: 121 - function: main - destination: custom - - path: javascript/main.js - line: 162 - function: global - destination: custom - - path: python/main.py - line: 161 - function: main - destination: custom - - path: ruby/main.rb - line: 135 - function: global - destination: custom - - path: typescript/main.ts - line: 306 - function: global - destination: custom - properties: - foo: - type: string - custom_event1: + FailedPayment: implementations: - - path: go/main.go - line: 122 - function: main - destination: custom - - path: javascript/main.js - line: 163 - function: global - destination: custom - - path: python/main.py - line: 162 - function: main - destination: custom - - path: ruby/main.rb - line: 136 - function: global - destination: custom - path: typescript/main.ts - line: 307 + line: 361 function: global destination: custom properties: - foo: + containerSection: type: string - custom_event2: - implementations: - - path: go/main.go - line: 123 - function: main - destination: custom - - path: javascript/main.js - line: 164 - function: global - destination: custom - - path: python/main.py - line: 163 - function: main - destination: custom - - path: ruby/main.rb - line: 137 - function: global - destination: custom - - path: typescript/main.ts - line: 308 - function: global - destination: custom - properties: - userId: - type: string - foo: - type: string - custom_event3: + amount: + type: number + InitiatedPayment: implementations: - - path: go/main.go - line: 124 - function: main - destination: custom - - path: javascript/main.js - line: 165 - function: global - destination: custom - - path: python/main.py - line: 164 - function: main - destination: custom - - path: ruby/main.rb - line: 138 - function: global - destination: custom - path: typescript/main.ts - line: 309 - function: global - destination: custom - properties: - foo: - type: string - userEmail: - type: string - custom_event4: - implementations: - - path: go/main.go - line: 125 - function: main - destination: custom - - path: javascript/main.js - line: 166 - function: global - destination: custom - - path: python/main.py - line: 165 - function: main - destination: custom - - path: ruby/main.rb - line: 139 + line: 349 function: global destination: custom - path: typescript/main.ts - line: 310 - function: global + line: 422 + function: trackInitiatedPayment destination: custom properties: - userId: + containerSection: type: string - userAddress: - type: object - properties: - city: - type: string - userEmail: + tierCartIntent: type: string - foo: - type: string - custom_module_event: + ViewedAttorneyAgreement: implementations: - - path: javascript/main.js - line: 178 - function: global - destination: custom - - path: python/main.py - line: 173 - function: main - destination: custom - path: typescript/main.ts - line: 322 - function: global + line: 375 + function: handleView destination: custom - properties: - userId: - type: string - order_id: - type: string - foo: - type: string + properties: {} diff --git a/tests/fixtures/typescript/constants.ts b/tests/fixtures/typescript/constants.ts index a984d5a..42513e5 100644 --- a/tests/fixtures/typescript/constants.ts +++ b/tests/fixtures/typescript/constants.ts @@ -2,4 +2,8 @@ export const TRACKING_EVENTS = { ECOMMERCE_PURCHASE: 'ecommerce_purchase', } +export const TRACKING_EVENTS_FROZEN = Object.freeze({ + ECOMMERCE_PURCHASE: 'ecommerce_purchase_frozen', +}); + export const ECOMMERCE_PURCHASE_V2 = 'ecommerce_purchase_v2'; diff --git a/tests/fixtures/typescript/main.ts b/tests/fixtures/typescript/main.ts index ad94c46..b3e9e6b 100644 --- a/tests/fixtures/typescript/main.ts +++ b/tests/fixtures/typescript/main.ts @@ -282,14 +282,14 @@ customTrackFunction('user888', 'custom_event_v2', customParams); // ----------------------------------------------------------------------------- // Event name is a const/pointer, not a string literal // ----------------------------------------------------------------------------- -import { TRACKING_EVENTS, ECOMMERCE_PURCHASE_V2 } from "./constants"; +import { TRACKING_EVENTS, TRACKING_EVENTS_FROZEN, ECOMMERCE_PURCHASE_V2 } from "./constants"; const purchaseEvent = { orderId: 'order_123', total: 99.99, items: ['sku_1', 'sku_2'] }; customTrackFunction('user555', TRACKING_EVENTS.ECOMMERCE_PURCHASE, purchaseEvent); - +mixpanel.track(TRACKING_EVENTS_FROZEN.ECOMMERCE_PURCHASE, {orderId: purchaseEvent.orderId, total: purchaseEvent.total, items: purchaseEvent.items}); analytics.track(ECOMMERCE_PURCHASE_V2, {...purchaseEvent}); // ----------------------------------------------------------------------------- @@ -323,3 +323,107 @@ CustomModule.track('user333', 'custom_module_event', { order_id: 'order_xyz', foo: 'bar' }); + +// ----------------------------------------------------------------------------- +// Additional Object.freeze constant and customTrackFunction6 patterns (new tests) +// ----------------------------------------------------------------------------- + +export const TELEMETRY_EVENTS = Object.freeze({ + VIEWED_TRANSITION: 'ViewedTransition', + INITIATED_PAYMENT: 'InitiatedPayment', + FAILED_PAYMENT: 'FailedPayment', + VIEWED_ATTORNEY_AGREEMENT: 'ViewedAttorneyAgreement', + ACCEPTED_ATTORNEY_AGREEMENT: 'AcceptedAttorneyAgreement', + DECLINED_ATTORNEY_AGREEMENT_FOR_REFUND: 'DeclinedAttorneyAgreementForRefund', + SUCCEEDED_PAYMENT: 'SucceededPayment', +}); + +declare function customTrackFunction5(EVENT_NAME: string, PROPERTIES: Record): void; +declare function customTrackFunction6(EVENT_NAME: string, PROPERTIES: Record): void; +declare function customTrackFunction7(EVENT_NAME: string, PROPERTIES: Record): void; + +declare function dispatch(action: any): void; + +// Nested inside another function call (e.g., Redux dispatch pattern) +dispatch( + customTrackFunction7(TELEMETRY_EVENTS.INITIATED_PAYMENT, { + containerSection: 'PaymentPage', + tierCartIntent: 'Gold', + }) +); + +// Variable reference as properties argument +const paymentArgs = { + containerSection: 'Checkout', + amount: 99.99, +}; +dispatch( + customTrackFunction5(TELEMETRY_EVENTS.FAILED_PAYMENT, paymentArgs) +); + +// Member expression chain: this.props.customTrackFunction6 +class ExampleComponent { + props: { customTrackFunction6: (evt: string, props: Record) => void }; + + constructor() { + this.props = { + customTrackFunction6: () => {}, + }; + } + + handleView() { + this.props.customTrackFunction6(TELEMETRY_EVENTS.VIEWED_ATTORNEY_AGREEMENT, {}); + } +} + +// ----------------------------------------------------------------------------- +// Redux-style mapDispatchToActions with nested customTrackFunction6 patterns +// ----------------------------------------------------------------------------- + +interface ExplicitPropsRedux { + tier: string; + containerSection: string; + applicationFeeInCents: number; +} + +interface MappedProps {} + +type GlobalErrorObject = { type: string; payload: any }; + +type ActionProps = Record; + +declare function closeModal(...args: any[]): void; +declare function postTelemetryWithConversion( + eventName: string, + props: Record, + conversionEvent: string, + extra: Record, + urls: string[], + destinations: string[] +): void; + +declare const CONVERSION_TRACKING_EVENTS: { PAYMENT: string }; +declare const CONVERSION_TRACKING_DESTINATIONS: { FACEBOOK: string; GOOGLE: string }; + +declare function trackUserEvent(eventName: string, props: Record): void; // alias to customTrackFunction6 + +function mapDispatchToActions(dispatch: Function, ownProps: ExplicitPropsRedux & MappedProps): ActionProps { + return { + closeModal: (...args: any[]) => dispatch(closeModal(...args)), + setGlobalError: ({ type, payload }: GlobalErrorObject) => dispatch({ type, payload }), + + // Variable-only properties argument + trackFailedPayment: (args: Record) => dispatch( + customTrackFunction6(TELEMETRY_EVENTS.FAILED_PAYMENT, args) + ), + + // Object literal with spread + additional props + trackInitiatedPayment: (args: Record) => dispatch( + customTrackFunction7(TELEMETRY_EVENTS.INITIATED_PAYMENT, { + ...args, + containerSection: ownProps.containerSection, + tierCartIntent: ownProps.tier, + }) + ), + }; +} diff --git a/tests/fixtures/typescript/tracking-schema-typescript.yaml b/tests/fixtures/typescript/tracking-schema-typescript.yaml index d1b8b71..2ae5c30 100644 --- a/tests/fixtures/typescript/tracking-schema-typescript.yaml +++ b/tests/fixtures/typescript/tracking-schema-typescript.yaml @@ -2,8 +2,8 @@ version: 1 source: repository: git@github.com:fliskdata/analyze-tracking.git - commit: f9eeddec7c52731efddc5ca5f8fbd8bf4c6a89cf - timestamp: '2025-06-25T03:57:29Z' + commit: c5cb9b287b787da97c8d3d41b229ed14a8650ac9 + timestamp: '2025-06-26T04:25:24Z' events: order_completed: implementations: @@ -334,6 +334,21 @@ events: type: string userId: type: string + ecommerce_purchase_frozen: + implementations: + - path: main.ts + line: 292 + function: global + destination: mixpanel + properties: + orderId: + type: string + total: + type: number + items: + type: array + items: + type: string ecommerce_purchase_v2: implementations: - path: main.ts @@ -420,3 +435,36 @@ events: type: string userId: type: string + FailedPayment: + implementations: + - path: main.ts + line: 361 + function: global + destination: custom + properties: + containerSection: + type: string + amount: + type: number + InitiatedPayment: + implementations: + - path: main.ts + line: 349 + function: global + destination: custom + - path: main.ts + line: 422 + function: trackInitiatedPayment + destination: custom + properties: + containerSection: + type: string + tierCartIntent: + type: string + ViewedAttorneyAgreement: + implementations: + - path: main.ts + line: 375 + function: handleView + destination: custom + properties: {}