diff --git a/src/analyze/typescript/detectors/analytics-source.js b/src/analyze/typescript/detectors/analytics-source.js index 2ba6b73..1624421 100644 --- a/src/analyze/typescript/detectors/analytics-source.js +++ b/src/analyze/typescript/detectors/analytics-source.js @@ -48,7 +48,10 @@ function isCustomFunction(node, customFunction) { ts.isPropertyAccessExpression(node.expression) || ts.isCallExpression(node.expression) || // For chained calls like getTracker().track() ts.isElementAccessExpression(node.expression) || // For array/object access like trackers['analytics'].track() - (ts.isPropertyAccessExpression(node.expression?.expression) && ts.isThisExpression(node.expression.expression.expression)); // For class methods like this.analytics.track() + (node.expression?.expression && + ts.isPropertyAccessExpression(node.expression.expression) && + node.expression.expression.expression && + ts.isThisExpression(node.expression.expression.expression)); // For class methods like this.analytics.track() return canBeCustomFunction && node.expression.getText() === customFunction; } diff --git a/src/analyze/typescript/extractors/event-extractor.js b/src/analyze/typescript/extractors/event-extractor.js index 7648a66..6b01916 100644 --- a/src/analyze/typescript/extractors/event-extractor.js +++ b/src/analyze/typescript/extractors/event-extractor.js @@ -50,7 +50,7 @@ function extractGoogleAnalyticsEvent(node, checker, sourceFile) { } // gtag('event', 'event_name', { properties }) - const eventName = getStringValue(node.arguments[1]); + const eventName = getStringValue(node.arguments[1], checker, sourceFile); const propertiesNode = node.arguments[2]; return { eventName, propertiesNode }; @@ -79,7 +79,7 @@ function extractSnowplowEvent(node, checker, sourceFile) { const structEventArg = firstArg.arguments[0]; if (ts.isObjectLiteralExpression(structEventArg)) { const actionProperty = findPropertyByKey(structEventArg, 'action'); - const eventName = actionProperty ? getStringValue(actionProperty.initializer) : null; + const eventName = actionProperty ? getStringValue(actionProperty.initializer, checker, sourceFile) : null; return { eventName, propertiesNode: structEventArg }; } } @@ -93,7 +93,7 @@ function extractSnowplowEvent(node, checker, sourceFile) { const structEventArg = resolvedNode.arguments[0]; if (ts.isObjectLiteralExpression(structEventArg)) { const actionProperty = findPropertyByKey(structEventArg, 'action'); - const eventName = actionProperty ? getStringValue(actionProperty.initializer) : null; + const eventName = actionProperty ? getStringValue(actionProperty.initializer, checker, sourceFile) : null; return { eventName, propertiesNode: structEventArg }; } } @@ -115,7 +115,7 @@ function extractMparticleEvent(node, checker, sourceFile) { } // mParticle.logEvent('event_name', mParticle.EventType.Navigation, { properties }) - const eventName = getStringValue(node.arguments[0]); + const eventName = getStringValue(node.arguments[0], checker, sourceFile); const propertiesNode = node.arguments[2]; return { eventName, propertiesNode }; @@ -134,7 +134,7 @@ function extractDefaultEvent(node, checker, sourceFile) { } // provider.track('event_name', { properties }) - const eventName = getStringValue(node.arguments[0]); + const eventName = getStringValue(node.arguments[0], checker, sourceFile); const propertiesNode = node.arguments[1]; return { eventName, propertiesNode }; @@ -197,16 +197,121 @@ function processEventData(eventData, source, filePath, line, functionName, check /** * Gets string value from a TypeScript AST node * @param {Object} node - TypeScript AST node + * @param {Object} checker - TypeScript type checker + * @param {Object} sourceFile - TypeScript source file * @returns {string|null} String value or null */ -function getStringValue(node) { +function getStringValue(node, checker, sourceFile) { if (!node) return null; + + // Handle string literals (existing behavior) if (ts.isStringLiteral(node)) { return node.text; } + + // Handle property access expressions like TRACKING_EVENTS.ECOMMERCE_PURCHASE + if (ts.isPropertyAccessExpression(node)) { + return resolvePropertyAccessToString(node, checker, sourceFile); + } + + // Handle identifiers that might reference constants + if (ts.isIdentifier(node)) { + return resolveIdentifierToString(node, checker, sourceFile); + } + return null; } +/** + * Resolves a property access expression to its string value + * @param {Object} node - PropertyAccessExpression node + * @param {Object} checker - TypeScript type checker + * @param {Object} sourceFile - TypeScript source file + * @returns {string|null} String value or null + */ +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; + } + + // 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; + } + } + + return null; + } catch (error) { + return null; + } +} + +/** + * Resolves an identifier to its string value + * @param {Object} node - Identifier node + * @param {Object} checker - TypeScript type checker + * @param {Object} sourceFile - TypeScript source file + * @returns {string|null} String value or null + */ +function resolveIdentifierToString(node, checker, sourceFile) { + try { + const symbol = checker.getSymbolAtLocation(node); + if (!symbol) { + return null; + } + + // First try to resolve through value declaration + if (symbol.valueDeclaration) { + const declaration = symbol.valueDeclaration; + + // Handle variable declarations with string literal initializers + if (ts.isVariableDeclaration(declaration) && + declaration.initializer && + ts.isStringLiteral(declaration.initializer)) { + return declaration.initializer.text; + } + + // Handle const declarations with object literals containing string properties + if (ts.isVariableDeclaration(declaration) && + declaration.initializer && + ts.isObjectLiteralExpression(declaration.initializer)) { + // This case is handled by property access resolution + return null; + } + } + + // If value declaration doesn't exist or doesn't help, try type resolution + // This handles imported constants that are resolved through TypeScript's type system + const type = checker.getTypeOfSymbolAtLocation(symbol, node); + if (type && type.isStringLiteral && typeof type.isStringLiteral === 'function' && type.isStringLiteral()) { + return type.value; + } + + // Alternative approach for string literal types (different TypeScript versions) + if (type && type.flags && (type.flags & ts.TypeFlags.StringLiteral)) { + return type.value; + } + + return null; + } catch (error) { + return null; + } +} + /** * Finds a property by key in an ObjectLiteralExpression * @param {Object} objectNode - ObjectLiteralExpression node diff --git a/src/analyze/typescript/extractors/property-extractor.js b/src/analyze/typescript/extractors/property-extractor.js index 5420217..66ec3a3 100644 --- a/src/analyze/typescript/extractors/property-extractor.js +++ b/src/analyze/typescript/extractors/property-extractor.js @@ -34,6 +34,13 @@ function extractProperties(checker, node) { const properties = {}; for (const prop of node.properties) { + // Handle spread assignments like {...object} + if (ts.isSpreadAssignment(prop)) { + const spreadProperties = extractSpreadProperties(checker, prop); + Object.assign(properties, spreadProperties); + continue; + } + const key = getPropertyKey(prop); if (!key) continue; @@ -350,7 +357,7 @@ function resolveTypeSchema(checker, typeString) { * @returns {string|null} Literal type or null */ function getLiteralType(node) { - if (!node) return null; + if (!node || typeof node.kind === 'undefined') return null; if (ts.isStringLiteral(node)) return 'string'; if (ts.isNumericLiteral(node)) return 'number'; if (node.kind === ts.SyntaxKind.TrueKeyword || node.kind === ts.SyntaxKind.FalseKeyword) return 'boolean'; @@ -371,6 +378,51 @@ function isArrayType(typeString) { typeString.startsWith('readonly '); } +/** + * Extracts properties from a spread assignment + * @param {Object} checker - TypeScript type checker + * @param {Object} spreadNode - SpreadAssignment node + * @returns {Object.} + */ +function extractSpreadProperties(checker, spreadNode) { + if (!spreadNode.expression) { + return {}; + } + + // If the spread is an identifier, resolve it to its declaration + if (ts.isIdentifier(spreadNode.expression)) { + const symbol = checker.getSymbolAtLocation(spreadNode.expression); + if (symbol && symbol.declarations && symbol.declarations.length > 0) { + const declaration = symbol.declarations[0]; + + // If it's a variable declaration with an object literal initializer + if (ts.isVariableDeclaration(declaration) && declaration.initializer) { + if (ts.isObjectLiteralExpression(declaration.initializer)) { + // Extract properties directly from the object literal + return extractProperties(checker, declaration.initializer); + } + } + } + + // Fallback to the original identifier schema extraction + const identifierSchema = extractIdentifierSchema(checker, spreadNode.expression); + return identifierSchema.properties || {}; + } + + // If the spread is an object literal, extract its properties + if (ts.isObjectLiteralExpression(spreadNode.expression)) { + return extractProperties(checker, spreadNode.expression); + } + + // For other expressions, try to get the type and extract properties from it + try { + const spreadType = checker.getTypeAtLocation(spreadNode.expression); + return extractInterfaceProperties(checker, spreadType); + } catch (error) { + return {}; + } +} + /** * Extracts properties from a TypeScript interface or type * @param {Object} checker - TypeScript type checker diff --git a/src/analyze/typescript/utils/function-finder.js b/src/analyze/typescript/utils/function-finder.js index 00a9f3a..bbc1cdc 100644 --- a/src/analyze/typescript/utils/function-finder.js +++ b/src/analyze/typescript/utils/function-finder.js @@ -110,6 +110,16 @@ function findParentFunctionName(node) { } } + // Property declaration in class: myFunc = () => {} + if (ts.isPropertyDeclaration(parent) && parent.name) { + if (ts.isIdentifier(parent.name)) { + return parent.name.escapedText; + } + if (ts.isStringLiteral(parent.name)) { + return parent.name.text; + } + } + // Method property in object literal: { myFunc() {} } if (ts.isMethodDeclaration(parent) && parent.name) { return parent.name.escapedText; @@ -117,6 +127,7 @@ function findParentFunctionName(node) { // Binary expression assignment: obj.myFunc = () => {} if (ts.isBinaryExpression(parent) && + parent.operatorToken && parent.operatorToken.kind === ts.SyntaxKind.EqualsToken) { if (ts.isPropertyAccessExpression(parent.left)) { return parent.left.name.escapedText; diff --git a/src/analyze/typescript/utils/type-resolver.js b/src/analyze/typescript/utils/type-resolver.js index 23e8058..3270ab6 100644 --- a/src/analyze/typescript/utils/type-resolver.js +++ b/src/analyze/typescript/utils/type-resolver.js @@ -144,7 +144,7 @@ function isCustomType(typeString) { * @returns {string} Basic type string */ function getBasicTypeOfArrayElement(checker, element) { - if (!element) return 'any'; + if (!element || typeof element.kind === 'undefined') return 'any'; // Check for literal values first if (ts.isStringLiteral(element)) { diff --git a/tests/analyzeTypeScript.test.js b/tests/analyzeTypeScript.test.js index f7c0751..9acce13 100644 --- a/tests/analyzeTypeScript.test.js +++ b/tests/analyzeTypeScript.test.js @@ -29,7 +29,7 @@ test.describe('analyzeTsFile', () => { // Sort events by line number for consistent ordering events.sort((a, b) => a.line - b.line); - assert.strictEqual(events.length, 12); + assert.strictEqual(events.length, 14); // Test Google Analytics event const gaEvent = events.find(e => e.eventName === 'order_completed' && e.source === 'googleanalytics'); @@ -311,6 +311,36 @@ test.describe('analyzeTsFile', () => { } } }); + + // Test custom function event with constant reference + const constantEvent = events.find(e => e.eventName === 'ecommerce_purchase'); + assert.ok(constantEvent); + assert.strictEqual(constantEvent.source, 'custom'); + assert.strictEqual(constantEvent.functionName, 'global'); + assert.strictEqual(constantEvent.line, 290); + assert.deepStrictEqual(constantEvent.properties, { + orderId: { type: 'string' }, + total: { type: 'number' }, + items: { + type: 'array', + items: { type: 'string' } + } + }); + + // Test imported constant event + const importedConstantEvent = events.find(e => e.eventName === 'ecommerce_purchase_v2'); + assert.ok(importedConstantEvent); + assert.strictEqual(importedConstantEvent.source, 'segment'); + assert.strictEqual(importedConstantEvent.functionName, 'global'); + assert.strictEqual(importedConstantEvent.line, 292); + assert.deepStrictEqual(importedConstantEvent.properties, { + orderId: { type: 'string' }, + total: { type: 'number' }, + items: { + type: 'array', + items: { type: 'string' } + } + }); }); test('should handle files without tracking events', () => { @@ -330,8 +360,8 @@ test.describe('analyzeTsFile', () => { const program = createProgram(testFilePath); const events = analyzeTsFile(testFilePath, program, null); - // Should find all events except the custom one - assert.strictEqual(events.length, 11); + // Should find all events except the custom ones + assert.strictEqual(events.length, 12); assert.strictEqual(events.find(e => e.source === 'custom'), undefined); }); @@ -488,25 +518,37 @@ test.describe('analyzeTsFile', () => { // Sort events by line number for consistent ordering events.sort((a, b) => a.line - b.line); - assert.strictEqual(events.length, 7); + // Updated count - 8 events for regular analysis (complex_operation is only detected with custom function) + assert.strictEqual(events.length, 8); + + // Test new Segment event from ComplexUploadComponent (regression test pattern) + const complexSegmentEvent = events.find(e => e.source === 'segment' && e.eventName === 'document_upload_clicked'); + assert.ok(complexSegmentEvent); + assert.strictEqual(complexSegmentEvent.eventName, 'document_upload_clicked'); + assert.strictEqual(complexSegmentEvent.functionName, 'onFileUploadClick'); // Arrow function methods now show proper names + assert.strictEqual(complexSegmentEvent.line, 53); + assert.deepStrictEqual(complexSegmentEvent.properties, { + documentId: { type: 'any' }, + documentType: { type: 'string' } + }); // Test PostHog event const posthogEvent = events.find(e => e.source === 'posthog'); assert.ok(posthogEvent); assert.strictEqual(posthogEvent.eventName, 'cart_viewed'); assert.strictEqual(posthogEvent.functionName, 'useEffect()'); - assert.strictEqual(posthogEvent.line, 15); + assert.strictEqual(posthogEvent.line, 89); assert.deepStrictEqual(posthogEvent.properties, { item_count: { type: 'number' }, total_value: { type: 'number' } }); - // Test Segment event - const segmentEvent = events.find(e => e.source === 'segment'); + // Test Segment event from useCallback + const segmentEvent = events.find(e => e.source === 'segment' && e.eventName === 'add_to_cart'); assert.ok(segmentEvent); assert.strictEqual(segmentEvent.eventName, 'add_to_cart'); assert.strictEqual(segmentEvent.functionName, 'useCallback(handleAddToCart)'); - assert.strictEqual(segmentEvent.line, 27); + assert.strictEqual(segmentEvent.line, 101); assert.deepStrictEqual(segmentEvent.properties, { product_id: { type: 'string' }, product_name: { type: 'string' }, @@ -518,7 +560,7 @@ test.describe('analyzeTsFile', () => { assert.ok(amplitudeEvent); assert.strictEqual(amplitudeEvent.eventName, 'item_added'); assert.strictEqual(amplitudeEvent.functionName, 'useCallback(handleAddToCart)'); - assert.strictEqual(amplitudeEvent.line, 34); + assert.strictEqual(amplitudeEvent.line, 108); assert.deepStrictEqual(amplitudeEvent.properties, { item_details: { type: 'object', @@ -537,7 +579,7 @@ test.describe('analyzeTsFile', () => { assert.ok(mixpanelEvent); assert.strictEqual(mixpanelEvent.eventName, 'remove_from_cart'); assert.strictEqual(mixpanelEvent.functionName, 'removeFromCart'); - assert.strictEqual(mixpanelEvent.line, 45); + assert.strictEqual(mixpanelEvent.line, 119); assert.deepStrictEqual(mixpanelEvent.properties, { product_id: { type: 'string' }, timestamp: { type: 'string' } @@ -548,7 +590,7 @@ test.describe('analyzeTsFile', () => { assert.ok(gaEvent); assert.strictEqual(gaEvent.eventName, 'begin_checkout'); assert.strictEqual(gaEvent.functionName, 'handleCheckout'); - assert.strictEqual(gaEvent.line, 56); + assert.strictEqual(gaEvent.line, 130); assert.deepStrictEqual(gaEvent.properties, { items: { type: 'array', @@ -566,7 +608,7 @@ test.describe('analyzeTsFile', () => { assert.ok(rudderstackEvent); assert.strictEqual(rudderstackEvent.eventName, 'checkout_started'); assert.strictEqual(rudderstackEvent.functionName, 'handleCheckout'); - assert.strictEqual(rudderstackEvent.line, 63); + assert.strictEqual(rudderstackEvent.line, 137); assert.deepStrictEqual(rudderstackEvent.properties, { products: { type: 'array', @@ -583,7 +625,7 @@ test.describe('analyzeTsFile', () => { assert.ok(mparticleEvent); assert.strictEqual(mparticleEvent.eventName, 'InitiateCheckout'); assert.strictEqual(mparticleEvent.functionName, 'handleCheckout'); - assert.strictEqual(mparticleEvent.line, 69); + assert.strictEqual(mparticleEvent.line, 143); assert.deepStrictEqual(mparticleEvent.properties, { cart_items: { type: 'array', @@ -594,6 +636,9 @@ test.describe('analyzeTsFile', () => { }, checkout_step: { type: 'number' } }); + + // Note: cart_update event is only detected with custom function detection + // Note: complex_operation event is only detected with custom function detection }); test('should correctly analyze React TypeScript file with custom function', () => { @@ -601,14 +646,205 @@ test.describe('analyzeTsFile', () => { const program = createProgram(reactFilePath); const events = analyzeTsFile(reactFilePath, program, 'tracker.track'); - console.log({ events }); - const trackEvent = events.find(e => e.source === 'custom'); + // Should find both tracker.track events (cart_update and complex_operation) + const trackEvents = events.filter(e => e.source === 'custom'); + assert.strictEqual(trackEvents.length, 2); - assert.strictEqual(trackEvent.eventName, 'cart_update'); - assert.strictEqual(trackEvent.functionName, 'trackCartUpdate'); - assert.strictEqual(trackEvent.line, 81); - assert.deepStrictEqual(trackEvent.properties, { + const cartUpdateEvent = trackEvents.find(e => e.eventName === 'cart_update'); + assert.ok(cartUpdateEvent); + assert.strictEqual(cartUpdateEvent.eventName, 'cart_update'); + assert.strictEqual(cartUpdateEvent.functionName, 'trackCartUpdate'); + assert.strictEqual(cartUpdateEvent.line, 155); + assert.deepStrictEqual(cartUpdateEvent.properties, { cart_size: { type: 'number' } }); + + const complexOpEvent = trackEvents.find(e => e.eventName === 'complex_operation'); + assert.ok(complexOpEvent); + assert.strictEqual(complexOpEvent.eventName, 'complex_operation'); + assert.strictEqual(complexOpEvent.functionName, 'handleComplexOperation'); + assert.strictEqual(complexOpEvent.line, 62); + assert.deepStrictEqual(complexOpEvent.properties, { + hasRef: { type: 'boolean' }, + timestamp: { type: 'number' } + }); + }); + + // Regression tests for "Cannot read properties of undefined (reading 'kind')" fix + test('should handle complex React class component patterns without crashing (regression test)', () => { + const reactFilePath = path.join(fixturesDir, 'typescript-react', 'main.tsx'); + const program = createProgram(reactFilePath); + + // This should not throw any errors - the main test is that it doesn't crash + assert.doesNotThrow(() => { + const events = analyzeTsFile(reactFilePath, program); + // Should complete analysis without throwing undefined .kind errors + assert.ok(Array.isArray(events)); + assert.ok(events.length > 0); + }); + }); + + test('should handle complex class component with custom function detection without crashing', () => { + const reactFilePath = path.join(fixturesDir, 'typescript-react', 'main.tsx'); + const program = createProgram(reactFilePath); + + // This was the specific case that was causing "Cannot read properties of undefined (reading 'kind')" + assert.doesNotThrow(() => { + const events = analyzeTsFile(reactFilePath, program, 'track'); + assert.ok(Array.isArray(events)); + + // Should find the analytics.track call when looking for 'track' custom function + const analyticsEvent = events.find(e => e.eventName === 'document_upload_clicked'); + assert.ok(analyticsEvent); + assert.strictEqual(analyticsEvent.source, 'segment'); + assert.strictEqual(analyticsEvent.line, 53); + assert.deepStrictEqual(analyticsEvent.properties, { + documentId: { type: 'any' }, + documentType: { type: 'string' } + }); + }); + }); + + test('should handle various custom function detection patterns without undefined errors', () => { + const reactFilePath = path.join(fixturesDir, 'typescript-react', 'main.tsx'); + const program = createProgram(reactFilePath); + + // Test various custom function patterns that could trigger the bug + const customFunctionTests = [ + 'track', + 'analytics.track', + 'tracker.track', + 'this.track', + 'mixpanel.track', + 'nonexistent.function' + ]; + + customFunctionTests.forEach(customFunction => { + assert.doesNotThrow(() => { + const events = analyzeTsFile(reactFilePath, program, customFunction); + assert.ok(Array.isArray(events)); + }, `Should not throw error with custom function: ${customFunction}`); + }); + }); + + test('should handle nested property access expressions in custom function detection', () => { + const reactFilePath = path.join(fixturesDir, 'typescript-react', 'main.tsx'); + const program = createProgram(reactFilePath); + + // Test deeply nested property access that could cause undefined node traversal + const complexCustomFunctions = [ + 'this.props.analytics.track', + 'window.analytics.track', + 'deep.nested.property.track', + 'undefined.property.access' + ]; + + complexCustomFunctions.forEach(customFunction => { + assert.doesNotThrow(() => { + const events = analyzeTsFile(reactFilePath, program, customFunction); + assert.ok(Array.isArray(events)); + }, `Should not crash with complex custom function: ${customFunction}`); + }); + }); + + test('should correctly identify React class method contexts without undefined errors', () => { + const reactFilePath = path.join(fixturesDir, 'typescript-react', 'main.tsx'); + const program = createProgram(reactFilePath); + + const events = analyzeTsFile(reactFilePath, program); + + // Should find the analytics.track call in the arrow function method + const analyticsEvent = events.find(e => e.eventName === 'document_upload_clicked'); + assert.ok(analyticsEvent); + assert.strictEqual(analyticsEvent.functionName, 'onFileUploadClick'); // Arrow function methods now show proper names + assert.strictEqual(analyticsEvent.source, 'segment'); + }); + + test('should handle TypeScript React component with complex type intersections', () => { + const reactFilePath = path.join(fixturesDir, 'typescript-react', 'main.tsx'); + const program = createProgram(reactFilePath); + + // The file has complex type intersections: MappedProps & ExplicitProps & ActionProps + // This should not cause AST traversal issues + assert.doesNotThrow(() => { + const events = analyzeTsFile(reactFilePath, program, 'uploadError'); + assert.ok(Array.isArray(events)); + }); + }); + + test('should handle React refs and generic type parameters without errors', () => { + const reactFilePath = path.join(fixturesDir, 'typescript-react', 'main.tsx'); + const program = createProgram(reactFilePath); + + // The file uses React.createRef() which creates complex AST nodes + assert.doesNotThrow(() => { + const events = analyzeTsFile(reactFilePath, program, 'open'); + assert.ok(Array.isArray(events)); + }); + }); + + test('should handle both React functional and class components correctly', () => { + const reactFilePath = path.join(fixturesDir, 'typescript-react', 'main.tsx'); + const program = createProgram(reactFilePath); + + // Should work without errors for file containing both patterns + assert.doesNotThrow(() => { + const events = analyzeTsFile(reactFilePath, program, 'track'); + + assert.ok(Array.isArray(events)); + + // Should have events from both functional and class components + assert.ok(events.length > 0); + + // Should have functional component events (from hooks) + const functionalEvents = events.filter(e => e.functionName.includes('useCallback') || e.functionName.includes('useEffect')); + assert.ok(functionalEvents.length > 0); + + // Should have class component events (they now show proper method names) + const classEvents = events.filter(e => e.functionName === 'onFileUploadClick' || e.functionName === 'handleComplexOperation'); + assert.ok(classEvents.length > 0); + }); + }); + + test('should handle edge cases in isCustomFunction without undefined property access', () => { + const reactFilePath = path.join(fixturesDir, 'typescript-react', 'main.tsx'); + const program = createProgram(reactFilePath); + + // These edge cases were specifically causing the "reading 'kind'" error + const edgeCaseCustomFunctions = [ + 'track', // matches .track in analytics.track + 'current', // matches .current in dropzoneRef.current + 'props', // matches this.props + 'state' // common React property + ]; + + edgeCaseCustomFunctions.forEach(customFunction => { + assert.doesNotThrow(() => { + const events = analyzeTsFile(reactFilePath, program, customFunction); + assert.ok(Array.isArray(events)); + }, `Should handle edge case custom function: ${customFunction}`); + }); + }); + + test('should preserve correct event extraction while fixing undefined errors', () => { + const reactFilePath = path.join(fixturesDir, 'typescript-react', 'main.tsx'); + const program = createProgram(reactFilePath); + + // Verify that our fix doesn't break the actual tracking detection + const events = analyzeTsFile(reactFilePath, program); + + // Should correctly identify multiple tracking events including the complex class component + assert.ok(events.length >= 8); + + // Should still correctly identify the analytics.track call from complex component + const complexEvent = events.find(e => e.eventName === 'document_upload_clicked'); + assert.ok(complexEvent); + assert.strictEqual(complexEvent.source, 'segment'); + assert.strictEqual(complexEvent.functionName, 'onFileUploadClick'); + assert.strictEqual(complexEvent.line, 53); + assert.deepStrictEqual(complexEvent.properties, { + documentId: { type: 'any' }, + documentType: { type: 'string' } + }); }); }); diff --git a/tests/fixtures/tracking-schema-all.yaml b/tests/fixtures/tracking-schema-all.yaml index ab82f1b..a9fd27f 100644 --- a/tests/fixtures/tracking-schema-all.yaml +++ b/tests/fixtures/tracking-schema-all.yaml @@ -748,10 +748,51 @@ events: type: string retry: type: boolean + ecommerce_purchase: + implementations: + - path: typescript/main.ts + line: 290 + function: global + destination: custom + properties: + orderId: + type: string + total: + type: number + items: + type: array + items: + type: string + ecommerce_purchase_v2: + implementations: + - path: typescript/main.ts + line: 292 + function: global + destination: segment + properties: + orderId: + type: string + total: + type: number + items: + type: array + items: + type: string + document_upload_clicked: + implementations: + - path: typescript-react/main.tsx + line: 53 + function: onFileUploadClick + destination: segment + properties: + documentId: + type: any + documentType: + type: string cart_viewed: implementations: - path: typescript-react/main.tsx - line: 15 + line: 89 function: useEffect() destination: posthog properties: @@ -762,7 +803,7 @@ events: add_to_cart: implementations: - path: typescript-react/main.tsx - line: 27 + line: 101 function: useCallback(handleAddToCart) destination: segment properties: @@ -775,7 +816,7 @@ events: item_added: implementations: - path: typescript-react/main.tsx - line: 34 + line: 108 function: useCallback(handleAddToCart) destination: amplitude properties: @@ -795,7 +836,7 @@ events: remove_from_cart: implementations: - path: typescript-react/main.tsx - line: 45 + line: 119 function: removeFromCart destination: mixpanel properties: @@ -806,7 +847,7 @@ events: begin_checkout: implementations: - path: typescript-react/main.tsx - line: 56 + line: 130 function: handleCheckout destination: googleanalytics properties: @@ -830,7 +871,7 @@ events: checkout_started: implementations: - path: typescript-react/main.tsx - line: 63 + line: 137 function: handleCheckout destination: rudderstack properties: @@ -852,7 +893,7 @@ events: InitiateCheckout: implementations: - path: typescript-react/main.tsx - line: 69 + line: 143 function: handleCheckout destination: mparticle properties: diff --git a/tests/fixtures/typescript-react/main.tsx b/tests/fixtures/typescript-react/main.tsx index b7162ef..6fad16d 100644 --- a/tests/fixtures/typescript-react/main.tsx +++ b/tests/fixtures/typescript-react/main.tsx @@ -1,117 +1,199 @@ import React, { useCallback, useEffect, useState } from 'react'; import type { Product } from '../typescript/main'; +// Global declarations for analytics libraries (fixes linter errors) +declare global { + const analytics: any; + const posthog: any; + const amplitude: any; + const mixpanel: any; + const gtag: any; + const rudderanalytics: any; + const mParticle: any; + const tracker: any; +} + interface CartProps { - products: Product[]; + products: Product[]; } -export const ShoppingCart: React.FC = ({ products }) => { - const [cartItems, setCartItems] = useState([]); - const [isCheckingOut, setIsCheckingOut] = useState(false); - - // Direct tracking call in useEffect - useEffect(() => { - if (cartItems.length > 0) { - posthog.capture('cart_viewed', { - item_count: cartItems.length, - total_value: cartItems.reduce((sum, item) => sum + item.price, 0) - }); - } - }, [cartItems]); - - // Tracking in useCallback - const handleAddToCart = useCallback((product: Product) => { - setCartItems(prev => [...prev, product]); - - // Segment tracking - analytics.track('add_to_cart', { - product_id: product.id, - product_name: product.name, - price: product.price - }); - - // Amplitude tracking - amplitude.track('item_added', { - item_details: product, - cart_size: cartItems.length + 1 - }); - }, [cartItems]); - - // Arrow function with tracking - const removeFromCart = (productId: string) => { - setCartItems(prev => prev.filter(item => item.id !== productId)); - - // Mixpanel tracking Custom Function - mixpanel.track('remove_from_cart', { - product_id: productId, - timestamp: new Date().toISOString() - }); - }; - - // Regular method with tracking - function handleCheckout() { - setIsCheckingOut(true); - - // GA4 tracking - gtag('event', 'begin_checkout', { - items: cartItems, - value: cartItems.reduce((sum, item) => sum + item.price, 0), - currency: 'USD' - }); - - // Rudderstack tracking - rudderanalytics.track('checkout_started', { - products: cartItems, - total_items: cartItems.length - }); - - // mParticle tracking - mParticle.logEvent( - 'InitiateCheckout', - mParticle.EventType.Transaction, - { - cart_items: cartItems, - checkout_step: 1 - } - ); - } +// Regression test patterns: Add complex interfaces and types that caused the bug +interface ExplicitProps { + onDocumentUploaded?: () => void + acceptedFileTypes?: any +} - // Tracking with custom event builder - const trackCartUpdate = () => { - tracker.track('cart_update', { - cart_size: cartItems.length - }); - } +interface ActionProps { + uploadToPath: (params: { rawFile: any, message: string }) => void + uploadFinished: (params: { rawFile: any, message: string }) => void +} + +interface MappedProps { + documentId: number +} + +type ComplexProps = MappedProps & ExplicitProps & ActionProps + +// Complex class component - this pattern was triggering undefined .kind errors +class ComplexUploadComponent extends React.Component { + static defaultProps = { + acceptedFileTypes: { types: ['pdf', 'doc'], names: ['PDF', 'DOC'] } + }; + + dropzoneRef = React.createRef(); + + constructor(props: ComplexProps) { + super(props) + } + onFileUploadClick = (event: any) => { + event.preventDefault() + this.dropzoneRef.current?.open() + + analytics.track('document_upload_clicked', { + documentId: this.props.documentId, + documentType: 'document' + }) + } + + handleComplexOperation = () => { + const complexRef = this.dropzoneRef?.current?.someProperty?.deepProperty; + + tracker.track('complex_operation', { + hasRef: !!complexRef, + timestamp: Date.now() + }); + } + + render() { return ( -
-

Shopping Cart ({cartItems.length} items)

- - {products.map(product => ( -
- {product.name} - ${product.price} - -
- ))} - -
- {cartItems.map(item => ( -
- {item.name} - -
- ))} -
- - -
+
+ + +
+ ); + } +} + +export const ShoppingCart: React.FC = ({ products }) => { + const [cartItems, setCartItems] = useState([]); + const [isCheckingOut, setIsCheckingOut] = useState(false); + + // Direct tracking call in useEffect + useEffect(() => { + if (cartItems.length > 0) { + posthog.capture('cart_viewed', { + item_count: cartItems.length, + total_value: cartItems.reduce((sum, item) => sum + item.price, 0) + }); + } + }, [cartItems]); + + // Tracking in useCallback + const handleAddToCart = useCallback((product: Product) => { + setCartItems(prev => [...prev, product]); + + // Segment tracking + analytics.track('add_to_cart', { + product_id: product.id, + product_name: product.name, + price: product.price + }); + + // Amplitude tracking + amplitude.track('item_added', { + item_details: product, + cart_size: cartItems.length + 1 + }); + }, [cartItems]); + + // Arrow function with tracking + const removeFromCart = (productId: string) => { + setCartItems(prev => prev.filter(item => item.id !== productId)); + + // Mixpanel tracking Custom Function + mixpanel.track('remove_from_cart', { + product_id: productId, + timestamp: new Date().toISOString() + }); + }; + + // Regular method with tracking + function handleCheckout() { + setIsCheckingOut(true); + + // GA4 tracking + gtag('event', 'begin_checkout', { + items: cartItems, + value: cartItems.reduce((sum, item) => sum + item.price, 0), + currency: 'USD' + }); + + // Rudderstack tracking + rudderanalytics.track('checkout_started', { + products: cartItems, + total_items: cartItems.length + }); + + // mParticle tracking + mParticle.logEvent( + 'InitiateCheckout', + mParticle.EventType.Transaction, + { + cart_items: cartItems, + checkout_step: 1 + } ); + } + + // Tracking with custom event builder + const trackCartUpdate = () => { + tracker.track('cart_update', { + cart_size: cartItems.length + }); + } + + return ( +
+

Shopping Cart ({cartItems.length} items)

+ + { }} + uploadFinished={() => { }} + /> + + {products.map(product => ( +
+ {product.name} - ${product.price} + +
+ ))} + +
+ {cartItems.map(item => ( +
+ {item.name} + +
+ ))} +
+ + +
+ ); }; + +export { ComplexUploadComponent }; diff --git a/tests/fixtures/typescript-react/tracking-schema-typescript-react.yaml b/tests/fixtures/typescript-react/tracking-schema-typescript-react.yaml index 2c7976b..4478d00 100644 --- a/tests/fixtures/typescript-react/tracking-schema-typescript-react.yaml +++ b/tests/fixtures/typescript-react/tracking-schema-typescript-react.yaml @@ -5,11 +5,23 @@ source: commit: d3774b76a3d1528c76c4bdb500dc32ee508e6381 timestamp: '2025-05-29T15:55:59Z' events: + document_upload_clicked: + implementations: + - path: main.tsx + line: 53 + function: onFileUploadClick + destination: segment + properties: + documentId: + type: any + documentType: + type: string + cart_viewed: implementations: - path: main.tsx - line: 15 - function: ShoppingCart + line: 89 + function: useEffect() destination: posthog properties: item_count: @@ -20,8 +32,8 @@ events: add_to_cart: implementations: - path: main.tsx - line: 26 - function: handleAddToCart + line: 101 + function: useCallback(handleAddToCart) destination: segment properties: product_id: @@ -34,8 +46,8 @@ events: item_added: implementations: - path: main.tsx - line: 34 - function: handleAddToCart + line: 108 + function: useCallback(handleAddToCart) destination: amplitude properties: item_details: @@ -55,7 +67,7 @@ events: remove_from_cart: implementations: - path: main.tsx - line: 44 + line: 119 function: removeFromCart destination: mixpanel properties: @@ -67,7 +79,7 @@ events: begin_checkout: implementations: - path: main.tsx - line: 54 + line: 130 function: handleCheckout destination: googleanalytics properties: @@ -92,7 +104,7 @@ events: checkout_started: implementations: - path: main.tsx - line: 62 + line: 137 function: handleCheckout destination: rudderstack properties: @@ -115,7 +127,7 @@ events: InitiateCheckout: implementations: - path: main.tsx - line: 69 + line: 143 function: handleCheckout destination: mparticle properties: diff --git a/tests/fixtures/typescript/constants.ts b/tests/fixtures/typescript/constants.ts new file mode 100644 index 0000000..a984d5a --- /dev/null +++ b/tests/fixtures/typescript/constants.ts @@ -0,0 +1,5 @@ +export const TRACKING_EVENTS = { + ECOMMERCE_PURCHASE: 'ecommerce_purchase', +} + +export const ECOMMERCE_PURCHASE_V2 = 'ecommerce_purchase_v2'; diff --git a/tests/fixtures/typescript/main.ts b/tests/fixtures/typescript/main.ts index 82dc3bb..e870580 100644 --- a/tests/fixtures/typescript/main.ts +++ b/tests/fixtures/typescript/main.ts @@ -277,3 +277,16 @@ const customParams: CustomParams = { metadata: { source: 'unit_test', retry: false }, }; customTrackFunction('custom_event_v2', customParams); + +// ----------------------------------------------------------------------------- +// Event name is a const/pointer, not a string literal +// ----------------------------------------------------------------------------- +import { TRACKING_EVENTS, ECOMMERCE_PURCHASE_V2 } from "./constants"; +const purchaseEvent = { + orderId: 'order_123', + total: 99.99, + items: ['sku_1', 'sku_2'] +}; +customTrackFunction(TRACKING_EVENTS.ECOMMERCE_PURCHASE, purchaseEvent); + +analytics.track(ECOMMERCE_PURCHASE_V2, {...purchaseEvent}); diff --git a/tests/fixtures/typescript/tracking-schema-typescript.yaml b/tests/fixtures/typescript/tracking-schema-typescript.yaml index f1ec75a..442d641 100644 --- a/tests/fixtures/typescript/tracking-schema-typescript.yaml +++ b/tests/fixtures/typescript/tracking-schema-typescript.yaml @@ -315,3 +315,33 @@ events: type: string retry: type: boolean + ecommerce_purchase: + implementations: + - path: main.ts + line: 290 + function: global + destination: custom + properties: + orderId: + type: string + total: + type: number + items: + type: array + items: + type: string + ecommerce_purchase_v2: + implementations: + - path: main.ts + line: 292 + function: global + destination: segment + properties: + orderId: + type: string + total: + type: number + items: + type: array + items: + type: string