diff --git a/e2e/tests/sanity/sourcemap.test.js b/e2e/tests/sanity/sourcemap.test.js new file mode 100644 index 0000000..8b83b17 --- /dev/null +++ b/e2e/tests/sanity/sourcemap.test.js @@ -0,0 +1,7 @@ +"use strict"; +describe('Sourcemap support', () => { + test('should have comments and tags', () => { + expect(2 + 2).toBe(4); + }); +}); +//# sourceMappingURL=sourcemap.test.js.map \ No newline at end of file diff --git a/e2e/tests/sanity/sourcemap.test.js.map b/e2e/tests/sanity/sourcemap.test.js.map new file mode 100644 index 0000000..9ada718 --- /dev/null +++ b/e2e/tests/sanity/sourcemap.test.js.map @@ -0,0 +1,13 @@ +{ + "version": 3, + "file": "sourcemap.test.js", + "sourceRoot": "", + "sources": [ + "sourcemap.test.ts" + ], + "sourcesContent": [ + "/* Sourcemap test suite\n * --------------------\n *\n * Make sure you can see this comment in Allure Report\n * @tag sourcemap\n */\n\ndescribe('Sourcemap support', () => {\n /**\n * Compiled test should retain docblocks\n * thanks to source map alongside the file.\n */\n test('should have comments and tags', () => {\n // Check that this comment is shown in the test source code\n expect(2 + 2).toBe(4);\n });\n});\n" + ], + "names": [], + "mappings": ";AAOA,QAAQ,CAAC,mBAAmB,EAAE,GAAG,EAAE;IAKjC,IAAI,CAAC,+BAA+B,EAAE,GAAG,EAAE;QAEzC,MAAM,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;IACxB,CAAC,CAAC,CAAC;AACL,CAAC,CAAC,CAAC" +} diff --git a/src/options/helpers/file-navigator/getFileNavigator.ts b/src/options/helpers/file-navigator/getFileNavigator.ts index ffb81d9..7cdd92d 100644 --- a/src/options/helpers/file-navigator/getFileNavigator.ts +++ b/src/options/helpers/file-navigator/getFileNavigator.ts @@ -1,36 +1,12 @@ -import fs from 'node:fs/promises'; import path from 'node:path'; import type { KeyedHelperCustomizer } from 'jest-allure2-reporter'; -import { FileNavigator } from '../../../utils'; - -class FileNavigatorCache { - #cache = new Map>(); - - resolve(filePath: string): Promise { - const absolutePath = path.resolve(filePath); - if (!this.#cache.has(absolutePath)) { - this.#cache.set(absolutePath, this.#createNavigator(absolutePath)); - } - - return this.#cache.get(absolutePath)!; - } - - #createNavigator = async (filePath: string) => { - const sourceCode = await fs.readFile(filePath, 'utf8').catch(() => void 0); - return sourceCode == null ? undefined : new FileNavigator(sourceCode); - }; - - clear() { - this.#cache.clear(); - } - - static readonly instance = new FileNavigatorCache(); -} +import { FileNavigatorCache } from '../../../utils'; export const getFileNavigator: KeyedHelperCustomizer<'getFileNavigator'> = () => { - const cache = new FileNavigatorCache(); + const cache = FileNavigatorCache.instance; + return (maybeSegmentedFilePath) => { const filePath = Array.isArray(maybeSegmentedFilePath) ? maybeSegmentedFilePath.join(path.sep) diff --git a/src/reporter/JestAllure2Reporter.ts b/src/reporter/JestAllure2Reporter.ts index 3630243..b259e4c 100644 --- a/src/reporter/JestAllure2Reporter.ts +++ b/src/reporter/JestAllure2Reporter.ts @@ -25,7 +25,7 @@ import type { import { type ReporterConfig, resolveOptions } from '../options'; import { AllureMetadataProxy, MetadataSquasher } from '../metadata'; -import { compactArray, stringifyValues } from '../utils'; +import { compactArray, FileNavigatorCache, stringifyValues } from '../utils'; import { type AllureWriter, FileAllureWriter } from '../serialization'; import { log, optimizeForTracing } from '../logger'; @@ -155,6 +155,7 @@ export class JestAllure2Reporter extends JestMetadataReporter { async onTestFileStart(test: Test) { super.onTestFileStart(test); + await FileNavigatorCache.instance.scanSourcemap(test.path); const execute = this.#onTestFileStart.bind(this, test); const attempt = this.#attemptSync.bind(this, 'onTestFileStart()', execute); const testPath = path.relative(this._globalConfig.rootDir, test.path); diff --git a/src/runtime/modules/StepsDecorator.ts b/src/runtime/modules/StepsDecorator.ts index ba8bfe7..b3b1821 100644 --- a/src/runtime/modules/StepsDecorator.ts +++ b/src/runtime/modules/StepsDecorator.ts @@ -61,7 +61,7 @@ export class StepsDecorator { function resolveParameter(this: unknown[], parameter: UserParameter, index: number) { const { name = `${index}`, ...options } = - typeof parameter === 'string' ? { name: parameter } : parameter ?? {}; + typeof parameter === 'string' ? { name: parameter } : (parameter ?? {}); return [name, index, this[index], options, parameter] as ResolvedParameter; } diff --git a/src/utils/FileNavigatorCache.test.ts b/src/utils/FileNavigatorCache.test.ts new file mode 100644 index 0000000..7d11777 --- /dev/null +++ b/src/utils/FileNavigatorCache.test.ts @@ -0,0 +1,43 @@ +jest.mock('../logger'); + +import path from 'node:path'; + +import { log } from '../logger'; + +import { FileNavigatorCache } from './FileNavigatorCache'; + +const __FIXTURES__ = path.join(__dirname, '__fixtures__'); + +describe('FileNavigatorCache', () => { + test('should handle non-existent sourcemap', async () => { + const cache = new FileNavigatorCache(); + await cache.scanSourcemap(path.join(__FIXTURES__, 'non-existent')); + await expect(cache.resolve(path.join(__FIXTURES__, 'test.ts'))).resolves.toBeUndefined(); + expect(log.error).not.toHaveBeenCalled(); + }); + + test('should handle a broken sourcemap', async () => { + const cache = new FileNavigatorCache(); + await cache.scanSourcemap(path.join(__FIXTURES__, 'broken')); + await expect(cache.resolve(path.join(__FIXTURES__, 'test.ts'))).resolves.toBeUndefined(); + expect(log.error).toHaveBeenCalled(); + }); + + test('should handle an empty sourcemap', async () => { + const cache = new FileNavigatorCache(); + await cache.scanSourcemap(path.join(__FIXTURES__, 'empty')); + await expect(cache.resolve(path.join(__FIXTURES__, 'test.ts'))).resolves.toBeUndefined(); + }); + + test('should handle a simple sourcemap', async () => { + const cache = new FileNavigatorCache(); + await cache.scanSourcemap(path.join(__FIXTURES__, 'simple')); + await expect(cache.resolve(path.join(__FIXTURES__, 'test.ts'))).resolves.toBeDefined(); + }); + + test('should handle a sourcemap with a sourceRoot', async () => { + const cache = new FileNavigatorCache(); + await cache.scanSourcemap(path.join(__FIXTURES__, 'with-root')); + await expect(cache.resolve('/home/user/project/test.ts')).resolves.toBeDefined(); + }); +}); diff --git a/src/utils/FileNavigatorCache.ts b/src/utils/FileNavigatorCache.ts new file mode 100644 index 0000000..caa3faa --- /dev/null +++ b/src/utils/FileNavigatorCache.ts @@ -0,0 +1,67 @@ +import path from 'node:path'; +import fs from 'node:fs/promises'; +import type { SourceMapPayload } from 'node:module'; + +import { log } from '../logger'; + +import { FileNavigator } from './index'; + +export class FileNavigatorCache { + #cache = new Map>(); + + resolve(filePath: string): Promise { + const absolutePath = path.isAbsolute(filePath) ? filePath : path.resolve(filePath); + if (!this.#cache.has(absolutePath)) { + this.#cache.set(absolutePath, this.#createNavigator(absolutePath)); + } + + return this.#cache.get(absolutePath)!; + } + + async scanSourcemap(filePath: string): Promise { + const sourceMapPath = `${filePath}.map`; + + const doesNotExist = await fs.access(sourceMapPath).catch(() => true); + if (doesNotExist) return; + + const sourceMapRaw = await fs.readFile(sourceMapPath, 'utf8').catch((error) => { + log.error(error, `Failed to read sourcemap for: ${filePath}`); + }); + if (sourceMapRaw == null) return; + + let sourceMap: SourceMapPayload | undefined; + try { + sourceMap = JSON.parse(sourceMapRaw); + } catch (error) { + log.error(error, `Failed to parse sourcemap for: ${filePath}`); + } + if (!sourceMap) return; + + const { sourceRoot, sources, sourcesContent } = sourceMap; + if (!sources || !sourcesContent) return; + + const baseDirectory = + sourceRoot && path.isAbsolute(sourceRoot) ? sourceRoot : path.dirname(filePath); + for (const [index, content] of sourcesContent.entries()) { + const source = sources[index]; + if (!content || !source) continue; + + const sourcePath = path.isAbsolute(source) ? source : path.resolve(baseDirectory, source); + if (this.#cache.has(sourcePath)) continue; + + const navigator = new FileNavigator(content); + this.#cache.set(sourcePath, Promise.resolve(navigator)); + } + } + + #createNavigator = async (filePath: string) => { + const sourceCode = await fs.readFile(filePath, 'utf8').catch(() => void 0); + return sourceCode == null ? undefined : new FileNavigator(sourceCode); + }; + + clear() { + this.#cache.clear(); + } + + static readonly instance = new FileNavigatorCache(); +} diff --git a/src/utils/__fixtures__/broken.map b/src/utils/__fixtures__/broken.map new file mode 100644 index 0000000..98232c6 --- /dev/null +++ b/src/utils/__fixtures__/broken.map @@ -0,0 +1 @@ +{ diff --git a/src/utils/__fixtures__/empty.map b/src/utils/__fixtures__/empty.map new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/src/utils/__fixtures__/empty.map @@ -0,0 +1 @@ +{} diff --git a/src/utils/__fixtures__/no-content.map b/src/utils/__fixtures__/no-content.map new file mode 100644 index 0000000..21b34fb --- /dev/null +++ b/src/utils/__fixtures__/no-content.map @@ -0,0 +1,3 @@ +{ + "sources": ["test.ts"] +} diff --git a/src/utils/__fixtures__/simple.map b/src/utils/__fixtures__/simple.map new file mode 100644 index 0000000..520a0ff --- /dev/null +++ b/src/utils/__fixtures__/simple.map @@ -0,0 +1,4 @@ +{ + "sources": ["test.ts"], + "sourcesContent": ["export default 42;"] +} diff --git a/src/utils/__fixtures__/with-root.map b/src/utils/__fixtures__/with-root.map new file mode 100644 index 0000000..912a083 --- /dev/null +++ b/src/utils/__fixtures__/with-root.map @@ -0,0 +1,5 @@ +{ + "sources": ["test.ts"], + "sourceRoot": "/home/user/project", + "sourcesContent": ["export default 42;"] +} diff --git a/src/utils/index.ts b/src/utils/index.ts index 19d9eeb..caeb6dc 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -5,6 +5,7 @@ export * from './autoIndent'; export * from './compactObject'; export * from './fastMove'; export * from './FileNavigator'; +export * from './FileNavigatorCache'; export * from './getFullExtension'; export * from './getStatusDetails'; export * from './hijackFunction';