diff --git a/tools/sourcemap-tools/src/ContentAppender.ts b/tools/sourcemap-tools/src/ContentAppender.ts deleted file mode 100644 index 3aad8273..00000000 --- a/tools/sourcemap-tools/src/ContentAppender.ts +++ /dev/null @@ -1,11 +0,0 @@ -export class ContentAppender { - public appendToJSON(json: string, keyValues: object) { - for (const [key, value] of Object.entries(keyValues)) { - // Replace closing bracket with additional key-values - // Keep the matched whitespaces at the end - json = json.replace(/}(\s*)$/, `,"${key}":${JSON.stringify(value)}}$1`); - } - - return json; - } -} diff --git a/tools/sourcemap-tools/src/SourceProcessor.ts b/tools/sourcemap-tools/src/SourceProcessor.ts index b85183bd..0d9e4ce2 100644 --- a/tools/sourcemap-tools/src/SourceProcessor.ts +++ b/tools/sourcemap-tools/src/SourceProcessor.ts @@ -2,6 +2,7 @@ import fs from 'fs'; import path from 'path'; import { BasicSourceMapConsumer, Position, RawSourceMap, SourceMapConsumer, SourceMapGenerator } from 'source-map'; import { DebugIdGenerator } from './DebugIdGenerator'; +import { appendBeforeWhitespaces } from './helpers/stringHelpers'; import { stringToUuid } from './helpers/stringToUuid'; import { ResultPromise } from './models/AsyncResult'; import { Err, Ok, Result } from './models/Result'; @@ -100,9 +101,14 @@ export class SourceProcessor { } const sourceSnippet = this._debugIdGenerator.generateSourceSnippet(debugId); - const sourceComment = this._debugIdGenerator.generateSourceComment(debugId); - const newSource = sourceSnippet + '\n' + source + '\n' + sourceComment; + const shebang = source.match(/^(#!.+\n)/)?.[1]; + const sourceWithSnippet = shebang + ? shebang + sourceSnippet + '\n' + source.substring(shebang.length) + : sourceSnippet + '\n' + source; + + const sourceComment = this._debugIdGenerator.generateSourceComment(debugId); + const newSource = appendBeforeWhitespaces(sourceWithSnippet, '\n' + sourceComment); // We need to offset the source map by amount of lines that we're inserting to the source code // Sourcemaps map code like this: @@ -110,7 +116,7 @@ export class SourceProcessor { // So if we add any code to generated code, mappings after that code will become invalid // We need to offset the mapping lines by sourceSnippetNewlineCount: // original code X:Y => generated code (A + sourceSnippetNewlineCount):B - const sourceSnippetNewlineCount = sourceSnippet.match(/\n/g)?.length ?? 0; + const sourceSnippetNewlineCount = (sourceSnippet.match(/\n/g)?.length ?? 0) + (shebang ? 1 : 0); const offsetSourceMapResult = await this.offsetSourceMap(sourceMap, 0, sourceSnippetNewlineCount + 1); if (offsetSourceMapResult.isErr()) { return offsetSourceMapResult; diff --git a/tools/sourcemap-tools/src/helpers/stringHelpers.ts b/tools/sourcemap-tools/src/helpers/stringHelpers.ts new file mode 100644 index 00000000..25ce8c6e --- /dev/null +++ b/tools/sourcemap-tools/src/helpers/stringHelpers.ts @@ -0,0 +1,17 @@ +/** + * Appends `value` to `str` before trailing whitespaces in `str`. + * @param str String to append to. + * @param value String to append. + * @example + * const str = 'abc\n\n'; + * const value = 'def'; + * const appended = appendBeforeWhitespaces(str, value); // 'abcdef\n\n' + */ +export function appendBeforeWhitespaces(str: string, value: string) { + const whitespaces = str.match(/\s*$/)?.[0]; + if (!whitespaces) { + return str + value; + } + + return str.substring(0, str.length - whitespaces.length) + value + whitespaces; +} diff --git a/tools/sourcemap-tools/src/index.ts b/tools/sourcemap-tools/src/index.ts index 4902bb75..8c861e3b 100644 --- a/tools/sourcemap-tools/src/index.ts +++ b/tools/sourcemap-tools/src/index.ts @@ -1,4 +1,3 @@ -export * from './ContentAppender'; export * from './DebugIdGenerator'; export * from './FileFinder'; export * from './Logger'; diff --git a/tools/sourcemap-tools/tests/ContentAppender.spec.ts b/tools/sourcemap-tools/tests/ContentAppender.spec.ts deleted file mode 100644 index c5b63f20..00000000 --- a/tools/sourcemap-tools/tests/ContentAppender.spec.ts +++ /dev/null @@ -1,119 +0,0 @@ -import { ContentAppender } from '../src'; - -describe('ContentAppender', () => { - describe('appendToJSON', () => { - it('should return a parseable object', () => { - const obj = { - a: '123', - b: '456', - }; - - const keyValues = { - x: 'x', - y: 123, - z: true, - }; - - const contentAppender = new ContentAppender(); - const actual = contentAppender.appendToJSON(JSON.stringify(obj), keyValues); - - expect(() => JSON.parse(actual)).not.toThrow(); - }); - - it('should return an object with new key values', () => { - const obj = { - a: '123', - b: '456', - }; - - const keyValues = { - x: 'x', - y: 123, - z: true, - }; - - const contentAppender = new ContentAppender(); - const actual = contentAppender.appendToJSON(JSON.stringify(obj), keyValues); - - expect(JSON.parse(actual)).toMatchObject(keyValues); - }); - - it('should return an object with old key values', () => { - const obj = { - a: '123', - b: '456', - }; - - const keyValues = { - x: 'x', - y: 123, - z: true, - }; - - const contentAppender = new ContentAppender(); - const actual = contentAppender.appendToJSON(JSON.stringify(obj), keyValues); - - expect(JSON.parse(actual)).toMatchObject(obj); - }); - - it('should return an object with old and new key values', () => { - const obj = { - a: '123', - b: '456', - }; - - const keyValues = { - x: 'x', - y: 123, - z: true, - }; - - const expected = { - ...obj, - ...keyValues, - }; - - const contentAppender = new ContentAppender(); - const actual = contentAppender.appendToJSON(JSON.stringify(obj), keyValues); - - expect(JSON.parse(actual)).toMatchObject(expected); - }); - - it('should return an object with old and new key values with whitespaces at the end of JSON', () => { - const obj = { - a: '123', - b: '456', - }; - - const keyValues = { - x: 'x', - y: 123, - z: true, - }; - - const expected = { - ...obj, - ...keyValues, - }; - - const contentAppender = new ContentAppender(); - const actual = contentAppender.appendToJSON(JSON.stringify(obj) + ' \n\n \n\t', keyValues); - - expect(JSON.parse(actual)).toMatchObject(expected); - }); - - it('should not remove whitespaces at the end of JSON', () => { - const expected = ' \n\n \n\t'; - const json = - JSON.stringify({ - a: '123', - b: '456', - }) + expected; - - const contentAppender = new ContentAppender(); - const actual = contentAppender.appendToJSON(json, { x: true }); - - expect(actual).toMatch(new RegExp(expected + '$')); - }); - }); -}); diff --git a/tools/sourcemap-tools/tests/SourceProcessor.spec.ts b/tools/sourcemap-tools/tests/SourceProcessor.spec.ts index 36a9fdc3..990e2c0a 100644 --- a/tools/sourcemap-tools/tests/SourceProcessor.spec.ts +++ b/tools/sourcemap-tools/tests/SourceProcessor.spec.ts @@ -14,6 +14,26 @@ describe('SourceProcessor', () => { mappings: 'AAAA,SAASA,MACLC,QAAQC,IAAI,cAAc,CAC9B,CAEAF,IAAI', }; + const sourceWithShebang = `#!shebang +function foo(){console.log("Hello World!")}foo();`; + const sourceWithShebangMap = { + version: 3, + file: 'source.js', + sources: ['source.js'], + names: ['foo', 'console', 'log'], + mappings: ';AACA,SAASA,MACLC,QAAQC,IAAI,cAAc,CAC9B,CACAF,IAAI', + }; + + const sourceWithShebangElsewhere = `function foo(){console.log("Hello World!")}foo(); +#!shebang`; + const sourceWithShebangElsewhereMap = { + version: 3, + file: 'source.js', + sources: ['source.js'], + names: ['foo', 'console', 'log'], + mappings: 'AACA,SAASA,MACLC,QAAQC,IAAI,cAAc,CAC9B,CACAF,IAAI', + }; + describe('processSourceAndSourceMap', () => { it('should append source snippet to the source on the first line', async () => { const expected = 'APPENDED_SOURCE'; @@ -28,6 +48,35 @@ describe('SourceProcessor', () => { expect(result.data.source).toMatch(new RegExp(`^${expected}\n`)); }); + it('should append source snippet to the source on the first line with source having shebang not on the first line', async () => { + const expected = 'APPENDED_SOURCE'; + const debugIdGenerator = new DebugIdGenerator(); + + jest.spyOn(debugIdGenerator, 'generateSourceSnippet').mockReturnValue(expected); + + const sourceProcessor = new SourceProcessor(debugIdGenerator); + const result = await sourceProcessor.processSourceAndSourceMap( + sourceWithShebangElsewhere, + sourceWithShebangElsewhereMap, + ); + + assert(result.isOk()); + expect(result.data.source).toMatch(new RegExp(`^${expected}\n`)); + }); + + it('should append source snippet to the source after shebang', async () => { + const expected = 'APPENDED_SOURCE'; + const debugIdGenerator = new DebugIdGenerator(); + + jest.spyOn(debugIdGenerator, 'generateSourceSnippet').mockReturnValue(expected); + + const sourceProcessor = new SourceProcessor(debugIdGenerator); + const result = await sourceProcessor.processSourceAndSourceMap(sourceWithShebang, sourceWithShebangMap); + + assert(result.isOk()); + expect(result.data.source).toMatch(new RegExp(`^(#!.+\n)${expected}\n`)); + }); + it('should append comment snippet to the source on the last line', async () => { const expected = 'APPENDED_COMMENT'; const debugIdGenerator = new DebugIdGenerator(); @@ -41,6 +90,59 @@ describe('SourceProcessor', () => { expect(result.data.source).toMatch(new RegExp(`\n${expected}$`)); }); + it('should not add any whitespaces at end if there were none before when appending comment snippet', async () => { + const source = `abc`; + const expected = 'APPENDED_COMMENT'; + const debugIdGenerator = new DebugIdGenerator(); + + jest.spyOn(debugIdGenerator, 'generateSourceComment').mockReturnValue(expected); + + const sourceProcessor = new SourceProcessor(debugIdGenerator); + const result = await sourceProcessor.processSourceAndSourceMap(source, sourceMap); + + assert(result.isOk()); + expect(result.data.source).not.toMatch(/\s+$/); + }); + + it('should leave end whitespaces as they are when appending comment snippet', async () => { + const whitespaces = `\n\n\n \n\t \n\r`; + const source = `abc${whitespaces}`; + const expected = 'APPENDED_COMMENT'; + const debugIdGenerator = new DebugIdGenerator(); + + jest.spyOn(debugIdGenerator, 'generateSourceComment').mockReturnValue(expected); + + const sourceProcessor = new SourceProcessor(debugIdGenerator); + const result = await sourceProcessor.processSourceAndSourceMap(source, sourceMap); + + assert(result.isOk()); + expect(result.data.source).toMatch(new RegExp(`${whitespaces}$`)); + }); + + it('should not touch the original source', async () => { + const debugIdGenerator = new DebugIdGenerator(); + + jest.spyOn(debugIdGenerator, 'generateSourceSnippet').mockReturnValue('APPENDED_SOURCE'); + + const sourceProcessor = new SourceProcessor(debugIdGenerator); + const result = await sourceProcessor.processSourceAndSourceMap(source, sourceMap); + + assert(result.isOk()); + expect(result.data.source).toContain(source); + }); + + it('should not touch the original sourcemap keys apart from mappings', async () => { + const debugIdGenerator = new DebugIdGenerator(); + + jest.spyOn(debugIdGenerator, 'generateSourceSnippet').mockReturnValue('APPENDED_SOURCE'); + + const sourceProcessor = new SourceProcessor(debugIdGenerator); + const result = await sourceProcessor.processSourceAndSourceMap(source, sourceMap); + + assert(result.isOk()); + expect(result.data.sourceMap).toMatchObject({ ...sourceMap, mappings: result.data.sourceMap.mappings }); + }); + it('should return sourcemap from DebugIdGenerator', async () => { const expected = { [SOURCEMAP_DEBUG_ID_KEY]: 'debugId' }; const debugIdGenerator = new DebugIdGenerator(); @@ -80,6 +182,61 @@ describe('SourceProcessor', () => { expect(actualPosition).toEqual(expectedPosition); }); + it('should offset sourcemap lines by number of newlines in source snippet + 1 with source having shebang not on the first line', async () => { + const debugIdGenerator = new DebugIdGenerator(); + const sourceProcessor = new SourceProcessor(debugIdGenerator); + const snippet = 'a\nb\nc\nd'; + const expectedNewLineCount = (snippet.match(/\n/g)?.length ?? 0) + 1; + + jest.spyOn(debugIdGenerator, 'generateSourceSnippet').mockReturnValue(snippet); + + const unmodifiedConsumer = await new SourceMapConsumer(sourceMap); + const expectedPosition = unmodifiedConsumer.originalPositionFor({ + line: 1, + column: source.indexOf('foo();'), + }); + + const result = await sourceProcessor.processSourceAndSourceMap( + sourceWithShebangElsewhere, + sourceWithShebangElsewhereMap, + ); + assert(result.isOk()); + + const modifiedConsumer = await new SourceMapConsumer(result.data.sourceMap); + const actualPosition = modifiedConsumer.originalPositionFor({ + line: 1 + expectedNewLineCount, + column: source.indexOf('foo();'), + }); + + expect(actualPosition).toEqual(expectedPosition); + }); + + it('should offset sourcemap lines by number of newlines in source with shebang with snippet + 3', async () => { + const debugIdGenerator = new DebugIdGenerator(); + const sourceProcessor = new SourceProcessor(debugIdGenerator); + const snippet = 'a\nb\nc\nd'; + const expectedNewLineCount = (snippet.match(/\n/g)?.length ?? 0) + 3; + + jest.spyOn(debugIdGenerator, 'generateSourceSnippet').mockReturnValue(snippet); + + const unmodifiedConsumer = await new SourceMapConsumer(sourceMap); + const expectedPosition = unmodifiedConsumer.originalPositionFor({ + line: 1, + column: source.indexOf('foo();'), + }); + + const result = await sourceProcessor.processSourceAndSourceMap(sourceWithShebang, sourceWithShebangMap); + assert(result.isOk()); + + const modifiedConsumer = await new SourceMapConsumer(result.data.sourceMap); + const actualPosition = modifiedConsumer.originalPositionFor({ + line: 1 + expectedNewLineCount, + column: source.indexOf('foo();'), + }); + + expect(actualPosition).toEqual(expectedPosition); + }); + it('should call process function with content from files', async () => { const sourcePath = path.join(__dirname, './testFiles/source.js'); const sourceMapPath = path.join(__dirname, './testFiles/source.js.map'); diff --git a/tools/sourcemap-tools/tests/helpers/stringHelpers.spec.ts b/tools/sourcemap-tools/tests/helpers/stringHelpers.spec.ts new file mode 100644 index 00000000..d36700b1 --- /dev/null +++ b/tools/sourcemap-tools/tests/helpers/stringHelpers.spec.ts @@ -0,0 +1,39 @@ +import { appendBeforeWhitespaces } from '../../src/helpers/stringHelpers'; + +describe('stringHelpers', () => { + it('should append string to the end when there are no trailing whitespaces', () => { + const str = 'abcdefghi'; + const appended = 'xyz'; + const expected = 'abcdefghixyz'; + + const actual = appendBeforeWhitespaces(str, appended); + expect(actual).toEqual(expected); + }); + + it('should append string with whitespace to the end when there are no trailing whitespaces', () => { + const str = 'abcdefghi'; + const appended = ' xyz '; + const expected = 'abcdefghi xyz '; + + const actual = appendBeforeWhitespaces(str, appended); + expect(actual).toEqual(expected); + }); + + it('should append string before whitespaces when there are trailing whitespaces', () => { + const str = 'abcdefghi \n\t\t'; + const appended = 'xyz'; + const expected = 'abcdefghixyz \n\t\t'; + + const actual = appendBeforeWhitespaces(str, appended); + expect(actual).toEqual(expected); + }); + + it('should append string with whitespace before whitespaces when there are trailing whitespaces', () => { + const str = 'abcdefghi \n\t\t'; + const appended = '\nxyz\n'; + const expected = 'abcdefghi\nxyz\n \n\t\t'; + + const actual = appendBeforeWhitespaces(str, appended); + expect(actual).toEqual(expected); + }); +});