Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
76 changes: 62 additions & 14 deletions workers/javascript/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { beautifyUserAgent } from './utils';
import { Collection } from 'mongodb';
import { parse } from '@babel/parser';
import traverse from '@babel/traverse';
import { extname } from 'path';
/* eslint-disable-next-line no-unused-vars */
import { memoize } from '../../../lib/memoize';

Expand Down Expand Up @@ -231,7 +232,11 @@ export default class JavascriptEventWorker extends EventWorker {

const originalContent = consumer.sourceContentFor(originalLocation.source);

functionContext = await this.getFunctionContext(originalContent, originalLocation.line) ?? originalLocation.name;
functionContext = await this.getFunctionContext(
originalContent,
originalLocation.line,
originalLocation.source
) ?? originalLocation.name;
} catch (e) {
HawkCatcher.send(e);
this.logger.error('Can\'t get function context');
Expand All @@ -253,28 +258,20 @@ export default class JavascriptEventWorker extends EventWorker {
*
* @param sourceCode - content of the source file
* @param line - number of the line from the stack trace
* @param sourcePath - original source path from the source map (used to pick parser plugins)
* @returns {string | null} - string of the function context or null if it could not be parsed
*/
private getFunctionContext(sourceCode: string, line: number): string | null {
private getFunctionContext(sourceCode: string, line: number, sourcePath?: string): string | null {
let functionName: string | null = null;
let className: string | null = null;
let isAsync = false;

try {
// @todo choose plugins based on source code file extention (related to possible jsx parser usage in future)
const parserPlugins = this.getBabelParserPluginsForFile(sourcePath);

const ast = parse(sourceCode, {
sourceType: 'module',
plugins: [
'jsx',
'typescript',
'classProperties',
'decorators',
'optionalChaining',
'nullishCoalescingOperator',
'dynamicImport',
'bigInt',
'topLevelAwait',
],
plugins: parserPlugins,
});

traverse(ast as any, {
Expand Down Expand Up @@ -454,4 +451,55 @@ export default class JavascriptEventWorker extends EventWorker {
this.logger.error(`Error on source-map consumer initialization: ${e}`);
}
}

/**
* Choose babel parser plugins based on source file extension
*
* @param sourcePath - original file path from source map (e.g. "src/App.tsx")
*/
private getBabelParserPluginsForFile(sourcePath?: string): any[] {
const basePlugins: string[] = [
'classProperties',
'decorators',
'optionalChaining',
'nullishCoalescingOperator',
'dynamicImport',
'bigInt',
'topLevelAwait',
];

/**
* Default - use only typescript plugin because it's more stable and less likely will produce errors
*/
let enableTypeScript = true;
let enableJSX = false;

if (sourcePath) {
// remove query/hash if there is any
const cleanPath = sourcePath.split('?')[0].split('#')[0];
const ext = extname(cleanPath).toLowerCase();

const isTs = ext === '.ts' || ext === '.d.ts';
const isTsx = ext === '.tsx';
const isJs = ext === '.js' || ext === '.mjs' || ext === '.cjs';
const isJsx = ext === '.jsx';

enableTypeScript = isTs || isTsx;
// JSX:
// - for .ts/.d.ts — DISABLE
// - for .tsx/.jsx — ENABLE
// - for .js — keep enabled, to not break App.js with JSX
enableJSX = isTsx || isJsx || isJs;
}

if (enableTypeScript) {
basePlugins.push('typescript');
}

if (enableJSX) {
basePlugins.push('jsx');
}

return basePlugins;
}
}
114 changes: 114 additions & 0 deletions workers/javascript/tests/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -442,4 +442,118 @@ describe('JavaScript event worker', () => {

await worker.finish();
});

it('should resolve function context for TypeScript function with angle-bracket type assertion', () => {
const worker = new JavascriptEventWorker();

const tsSource = `
const foo: string | null = 'bar';

function throwError() {
const value = <string>foo;
throw new Error(value);
}

export { throwError };
`;

/**
* String with throw new Error(...) is the 6th line (if counting from 1)
* 1: ''
* 2: const foo...
* 3: ''
* 4: function throwError() {
* 5: const value = <string>foo;
* 6: throw new Error(value);
* ...
*/
const context = (worker as any).getFunctionContext(tsSource, 6, 'example.ts');

/**
* We expect that the build with fixes will return the function name,
* but with the current configuration the jsx+typescript parser fails
* and getFunctionContext returns null.
*/
expect(context).toBe('throwError');
});

it('should resolve function context for TypeScript generic arrow function', () => {
const worker = new JavascriptEventWorker();

const tsSource = `
type User = {
id: string;
name: string;
};

const wrap = <T>(value: T): T => {
return value;
};

export const useUser = () => {
const user: User = wrap<User>({ id: '1', name: 'John' });

return user;
};
`;

/**
* String inside useUser - where we want to get context:
* 1: ''
* 2: type User = { ...
* ...
* 7: const wrap = <T>(value: T): T => {
* ...
* 12: export const useUser = () => {
* 13: const user: User = wrap<User>({ id: '1', name: 'John' });
* 14:
* 15: return user;
* 16: };
*/
const context = (worker as any).getFunctionContext(tsSource, 13, 'example.ts');

expect(context).toBe('useUser');
});

it('should resolve class method context for TypeScript class with type assertion', () => {
const worker = new JavascriptEventWorker();

const tsSource = `
class ApiClient {
private baseUrl: string = 'https://example.com';

public request() {
const raw = '{"ok":true}';
const parsed = <Record<string, unknown>>JSON.parse(raw);

if (!parsed.ok) {
throw new Error('Request failed');
}

return parsed;
}
}

export default ApiClient;
`;

/**
* String where we want to get context - inside the request method:
* 1: ''
* 2: class ApiClient {
* 3: private baseUrl...
* 4:
* 5: public request() {
* 6: const raw = '{"ok":true}';
* 7: const parsed = <Record<string, unknown>>JSON.parse(raw);
* 8:
* 9: if (!parsed.ok) {
* 10: throw new Error('Request failed');
* ...
*/
const context = (worker as any).getFunctionContext(tsSource, 7, 'example.ts');

// We expect "ApiClient.request"
expect(context).toBe('ApiClient.request');
});
});
Loading