diff --git a/tools/sourcemap-tools/src/DebugIdGenerator.ts b/tools/sourcemap-tools/src/DebugIdGenerator.ts index 98373da0..2dcbce47 100644 --- a/tools/sourcemap-tools/src/DebugIdGenerator.ts +++ b/tools/sourcemap-tools/src/DebugIdGenerator.ts @@ -11,10 +11,29 @@ export class DebugIdGenerator { return `//# ${SOURCE_DEBUG_ID_COMMENT}=${uuid}`; } - public addSourceMapKey(sourceMap: T, uuid: string): T & { [SOURCEMAP_DEBUG_ID_KEY]: string } { + public getSourceDebugId(source: string): string | undefined { + const regex = new RegExp(`^//# ${SOURCE_DEBUG_ID_COMMENT}=(.+)$`, 'm'); + const match = source.match(regex); + if (!match) { + return undefined; + } + + return match[1]; + } + + public addSourceMapDebugId(sourceMap: T, uuid: string): T & { [SOURCEMAP_DEBUG_ID_KEY]: string } { return { ...sourceMap, [SOURCEMAP_DEBUG_ID_KEY]: uuid, }; } + + public getSourceMapDebugId(sourcemap: object): string | undefined { + const debugId = (sourcemap as Record)[SOURCEMAP_DEBUG_ID_KEY]; + if (typeof debugId !== 'string') { + return undefined; + } + + return debugId; + } } diff --git a/tools/sourcemap-tools/src/FileFinder.ts b/tools/sourcemap-tools/src/FileFinder.ts new file mode 100644 index 00000000..5c98af54 --- /dev/null +++ b/tools/sourcemap-tools/src/FileFinder.ts @@ -0,0 +1,38 @@ +import fs from 'fs'; +import path from 'path'; +import { ResultPromise } from './models/AsyncResult'; +import { Ok } from './models/Result'; + +interface SearchOptions { + readonly recursive?: boolean; + readonly match?: RegExp; +} + +export class FileFinder { + public async find(dir: string, options?: SearchOptions): ResultPromise { + const result: string[] = []; + const files = await fs.promises.readdir(dir); + + for (const file of files) { + const fullPath = path.resolve(dir, file); + const stat = await fs.promises.stat(fullPath); + if (stat.isDirectory()) { + if (options?.recursive) { + const innerFindResult = await this.find(fullPath, options); + if (innerFindResult.isErr()) { + return innerFindResult; + } + files.push(...innerFindResult.data); + } + + continue; + } + + if (!options?.match || fullPath.match(options.match)) { + result.push(fullPath); + } + } + + return Ok(result); + } +} diff --git a/tools/sourcemap-tools/src/Logger.ts b/tools/sourcemap-tools/src/Logger.ts new file mode 100644 index 00000000..e51df282 --- /dev/null +++ b/tools/sourcemap-tools/src/Logger.ts @@ -0,0 +1,10 @@ +export interface Logger { + error(value: unknown | Error, ...args: unknown[]): void; + warn(value: unknown | Error, ...args: unknown[]): void; + info(value: unknown | Error, ...args: unknown[]): void; + debug(value: unknown | Error, ...args: unknown[]): void; + trace(value: unknown | Error, ...args: unknown[]): void; + log(level: LogLevel, value: unknown | Error, ...args: unknown[]): void; +} + +export type LogLevel = keyof Pick; diff --git a/tools/sourcemap-tools/src/SourceProcessor.ts b/tools/sourcemap-tools/src/SourceProcessor.ts index 9a04e9b5..2d594fcc 100644 --- a/tools/sourcemap-tools/src/SourceProcessor.ts +++ b/tools/sourcemap-tools/src/SourceProcessor.ts @@ -1,11 +1,58 @@ import fs from 'fs'; +import path from 'path'; import { BasicSourceMapConsumer, Position, RawSourceMap, SourceMapConsumer, SourceMapGenerator } from 'source-map'; import { DebugIdGenerator } from './DebugIdGenerator'; import { stringToUuid } from './helpers/stringToUuid'; +import { ResultPromise } from './models/AsyncResult'; +import { Err, Ok } from './models/Result'; + +export interface ProcessResult { + readonly debugId: string; + readonly source: string; + readonly sourceMap: RawSourceMap; +} + +export interface ProcessResultWithPaths extends ProcessResult { + readonly sourcePath: string; + readonly sourceMapPath: string; +} export class SourceProcessor { constructor(private readonly _debugIdGenerator: DebugIdGenerator) {} + public isSourceProcessed(source: string): boolean { + return !!this._debugIdGenerator.getSourceDebugId(source); + } + + public isSourceMapProcessed(sourceMap: RawSourceMap): boolean { + return !!this._debugIdGenerator.getSourceMapDebugId(sourceMap); + } + + public async isSourceFileProcessed(sourcePath: string): ResultPromise { + const readResult = await this.readFile(sourcePath); + if (readResult.isErr()) { + return readResult; + } + + return Ok(this.isSourceProcessed(readResult.data)); + } + + public async isSourceMapFileProcessed(sourceMapPath: string): ResultPromise { + const readResult = await this.readFile(sourceMapPath); + if (readResult.isErr()) { + return readResult; + } + + let sourcemap: RawSourceMap; + try { + sourcemap = JSON.parse(readResult.data) as RawSourceMap; + } catch (err) { + return Err('failed to parse sourcemap JSON'); + } + + return Ok(this.isSourceMapProcessed(sourcemap)); + } + /** * Adds required snippets and comments to source, and modifies sourcemap to include debug ID. * @param source Source content. @@ -13,7 +60,11 @@ export class SourceProcessor { * @param debugId Debug ID. If not provided, one will be generated from `source`. * @returns Used debug ID, new source and new sourcemap. */ - public async processSourceAndSourceMap(source: string, sourceMap: string | RawSourceMap, debugId?: string) { + public async processSourceAndSourceMap( + source: string, + sourceMap: string | RawSourceMap, + debugId?: string, + ): ResultPromise { if (!debugId) { debugId = stringToUuid(source); } @@ -30,38 +81,100 @@ export class SourceProcessor { // 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 offsetSourceMap = await this.offsetSourceMap(sourceMap, 0, sourceSnippetNewlineCount + 1); - const newSourceMap = this._debugIdGenerator.addSourceMapKey(offsetSourceMap, debugId); + const offsetSourceMapResult = await this.offsetSourceMap(sourceMap, 0, sourceSnippetNewlineCount + 1); + if (offsetSourceMapResult.isErr()) { + return offsetSourceMapResult; + } - return { debugId, source: newSource, sourceMap: newSourceMap }; + const newSourceMap = this._debugIdGenerator.addSourceMapDebugId(offsetSourceMapResult.data, debugId); + return Ok({ debugId, source: newSource, sourceMap: newSourceMap }); } /** * Adds required snippets and comments to source, and modifies sourcemap to include debug ID. * Will write modified content to the files. * @param sourcePath Path to the source. - * @param sourceMapPath Path to the sourcemap. + * @param sourceMapPath Path to the sourcemap. If not specified, will try to resolve from sourceMapURL. * @param debugId Debug ID. If not provided, one will be generated from `source`. * @returns Used debug ID. */ - public async processSourceAndSourceMapFiles(sourcePath: string, sourceMapPath: string, debugId?: string) { - const source = await fs.promises.readFile(sourcePath, 'utf8'); - const sourceMap = await fs.promises.readFile(sourceMapPath, 'utf8'); + public async processSourceAndSourceMapFiles( + sourcePath: string, + sourceMapPath?: string, + debugId?: string, + ): ResultPromise { + const sourceReadResult = await this.readFile(sourcePath); + if (sourceReadResult.isErr()) { + return sourceReadResult; + } + + const source = sourceReadResult.data; + if (!sourceMapPath) { + const match = source.match(/^\/\/# sourceMappingURL=(.+)$/m); + if (!match || !match[1]) { + return Err('Could not find source map for source.'); + } + + sourceMapPath = path.resolve(path.dirname(sourcePath), match[1]); + } + + const sourceMapReadResult = await this.readFile(sourceMapPath); + if (sourceMapReadResult.isErr()) { + return sourceMapReadResult; + } + + const sourceMap = sourceMapReadResult.data; + + const processResult = await this.processSourceAndSourceMap(source, sourceMap, debugId); + if (processResult.isErr()) { + return processResult; + } + + return Ok({ + ...processResult.data, + sourcePath, + sourceMapPath, + } as ProcessResultWithPaths); + } + + public async addSourcesToSourceMap( + sourceMap: string | RawSourceMap, + sourceMapPath: string, + ): ResultPromise { + if (typeof sourceMap === 'string') { + sourceMap = JSON.parse(sourceMap) as RawSourceMap; + } + + const sourceRoot = sourceMap.sourceRoot + ? path.resolve(path.dirname(sourceMapPath), sourceMap.sourceRoot) + : path.resolve(path.dirname(sourceMapPath)); - const result = await this.processSourceAndSourceMap(source, sourceMap, debugId); + const sourcesContent: string[] = []; + for (const sourcePath of sourceMap.sources) { + const readResult = await this.readFile(path.resolve(sourceRoot, sourcePath)); + if (readResult.isErr()) { + return readResult; + } - await fs.promises.writeFile(sourcePath, result.source, 'utf8'); - await fs.promises.writeFile(sourceMapPath, JSON.stringify(result.sourceMap), 'utf8'); + sourcesContent.push(readResult.data); + } - return result.debugId; + return Ok({ + ...sourceMap, + sourcesContent, + }); + } + + public doesSourceMapHaveSources(sourceMap: RawSourceMap): boolean { + return sourceMap.sources.length === sourceMap.sourcesContent?.length; } private async offsetSourceMap( sourceMap: string | RawSourceMap, fromLine: number, count: number, - ): Promise { - const sourceMapObj = typeof sourceMap === 'string' ? JSON.parse(sourceMap) : sourceMap; + ): ResultPromise { + const sourceMapObj = typeof sourceMap === 'string' ? (JSON.parse(sourceMap) as RawSourceMap) : sourceMap; const consumer = (await new SourceMapConsumer(sourceMapObj)) as BasicSourceMapConsumer; const newSourceMap = new SourceMapGenerator({ file: consumer.file, @@ -89,6 +202,14 @@ export class SourceProcessor { }); const newSourceMapJson = newSourceMap.toJSON(); - return { ...sourceMapObj, ...newSourceMapJson }; + return Ok({ ...sourceMapObj, ...newSourceMapJson }); + } + + private async readFile(file: string): ResultPromise { + try { + return Ok(await fs.promises.readFile(file, 'utf-8')); + } catch (err) { + return Err(`failed to read file: ${err instanceof Error ? err.message : 'unknown error'}`); + } } } diff --git a/tools/sourcemap-tools/src/SymbolUploader.ts b/tools/sourcemap-tools/src/SymbolUploader.ts index 0c0301ce..506ff710 100644 --- a/tools/sourcemap-tools/src/SymbolUploader.ts +++ b/tools/sourcemap-tools/src/SymbolUploader.ts @@ -1,6 +1,8 @@ import http from 'http'; import https from 'https'; import { Readable } from 'stream'; +import { ResultPromise } from './models/AsyncResult'; +import { Err, Ok, Result } from './models/Result'; interface CoronerUploadResponse { response: 'ok' | string; @@ -32,10 +34,10 @@ export class SymbolUploader { * Uploads the symbol to Backtrace. * @param content Symbol stream. */ - public async uploadSymbol(readable: Readable): Promise { + public async uploadSymbol(readable: Readable): ResultPromise { const protocol = this._url.protocol === 'https:' ? https : http; - return new Promise((resolve, reject) => { + return new Promise>((resolve, reject) => { const request = protocol.request( this._url, { @@ -45,7 +47,7 @@ export class SymbolUploader { }, (response) => { if (!response.statusCode) { - return reject(new Error('Failed to upload symbol: failed to make the request.')); + return resolve(Err('Failed to upload symbol: failed to make the request.')); } const data: Buffer[] = []; @@ -53,27 +55,29 @@ export class SymbolUploader { data.push(chunk); }); + response.on('error', reject); + response.on('end', () => { const rawResponse = Buffer.concat(data).toString('utf-8'); if (!response.statusCode || response.statusCode < 200 || response.statusCode >= 300) { - return reject( - new Error( - `Failed to upload symbol: ${response.statusCode}. Response data: ${rawResponse}`, - ), + return resolve( + Err(`Failed to upload symbol: ${response.statusCode}. Response data: ${rawResponse}`), ); } try { const responseData = JSON.parse(rawResponse) as CoronerUploadResponse; if (responseData.response === 'ok') { - return resolve({ - rxid: responseData._rxid, - }); + return resolve( + Ok({ + rxid: responseData._rxid, + }), + ); } else { - return reject(new Error(`Non-OK response received from Coroner: ${rawResponse}`)); + return resolve(Err(`Non-OK response received from Coroner: ${rawResponse}`)); } } catch (err) { - return reject(new Error(`Cannot parse response from Coroner: ${rawResponse}`)); + return resolve(Err(`Cannot parse response from Coroner: ${rawResponse}`)); } }); }, diff --git a/tools/sourcemap-tools/src/index.ts b/tools/sourcemap-tools/src/index.ts index 050b899d..4902bb75 100644 --- a/tools/sourcemap-tools/src/index.ts +++ b/tools/sourcemap-tools/src/index.ts @@ -1,5 +1,9 @@ export * from './ContentAppender'; export * from './DebugIdGenerator'; +export * from './FileFinder'; +export * from './Logger'; export * from './SourceProcessor'; export * from './SymbolUploader'; export * from './ZipArchive'; +export * from './models/AsyncResult'; +export * from './models/Result'; diff --git a/tools/sourcemap-tools/src/models/AsyncResult.ts b/tools/sourcemap-tools/src/models/AsyncResult.ts new file mode 100644 index 00000000..b81f593a --- /dev/null +++ b/tools/sourcemap-tools/src/models/AsyncResult.ts @@ -0,0 +1,103 @@ +import { Result, ResultErr, ResultOk, flatMap, wrapErr, wrapOk } from './Result'; + +export type ResultPromise = Promise>; + +export class AsyncResult { + constructor(private readonly _promise: Promise>) {} + + public static equip( + asyncResult: Result | (() => Result) | ResultPromise | (() => ResultPromise), + ): AsyncResult { + if (asyncResult instanceof Promise) { + return new AsyncResult(asyncResult); + } + + if (asyncResult instanceof ResultOk || asyncResult instanceof ResultErr) { + return new AsyncResult(new Promise((resolve) => resolve(asyncResult))); + } + + const fnResult = asyncResult(); + if (fnResult instanceof Promise) { + return new AsyncResult(fnResult); + } + + return new AsyncResult(new Promise((resolve) => resolve(fnResult))); + } + + public then( + transform: (data: T) => Result[] | Promise[]> | Promise>[], + ): AsyncResult; + public then(transform: (data: T) => Result | Promise>): AsyncResult; + public then(transform: (data: T) => N | Promise): AsyncResult; + public then( + transform: ( + data: T, + ) => + | Result + | Promise> + | Promise>[] + | Result[] + | Promise[]> + | N + | Promise, + ): AsyncResult { + return new AsyncResult( + this._promise.then((result) => { + if (!result.isOk()) { + return result; + } + + const transformResult = transform(result.data); + if (transformResult instanceof Promise) { + return transformResult.then((v) => { + if (Array.isArray(v)) { + return flatMap(v.map(wrapOk)); + } + + return wrapOk(v); + }); + } + + if (Array.isArray(transformResult)) { + return Promise.all(transformResult).then((r) => flatMap(r.map(wrapOk))); + } + + return wrapOk(transformResult); + }), + ); + } + + public thenErr(transform: (data: E) => Promise>): AsyncResult; + public thenErr(transform: (data: E) => Result): AsyncResult; + public thenErr(transform: (data: E) => Promise): AsyncResult; + public thenErr(transform: (data: E) => N): AsyncResult; + public thenErr(transform: (data: E) => Result | N | Promise>): AsyncResult; + public thenErr(transform: (data: E) => Result | N | Promise>): AsyncResult { + return new AsyncResult( + this._promise.then((result) => { + if (!result.isErr()) { + return result; + } + + const transformResult = transform(result.data); + if (transformResult instanceof Promise) { + return transformResult.then((v) => wrapErr(v)); + } + + return wrapErr(transformResult); + }), + ); + } + + public get inner() { + return this._promise; + } +} + +export function OkAsync(data: T): AsyncResult { + return new AsyncResult(new Promise((resolve) => resolve(new ResultOk(data)))); +} + +export function ErrAsync(data: E): AsyncResult { + return new AsyncResult(new Promise((resolve) => resolve(new ResultErr(data)))); +} diff --git a/tools/sourcemap-tools/src/models/Result.ts b/tools/sourcemap-tools/src/models/Result.ts new file mode 100644 index 00000000..1b12aa83 --- /dev/null +++ b/tools/sourcemap-tools/src/models/Result.ts @@ -0,0 +1,110 @@ +interface BaseResult { + map(fn: (data: T) => N): Result; + mapErr(fn: (data: E) => N): Result; + + isOk(): this is ResultOk; + isErr(): this is ResultErr; + + unwrap(): T; +} + +export class UnwrapError extends Error { + constructor(public readonly data: E) { + super('Operation has resulted in an error.'); + } +} + +export type Result = ResultOk | ResultErr; + +export class ResultOk implements BaseResult { + constructor(public readonly data: T) {} + + public map(transform: (data: T) => N): Result { + return new ResultOk(transform(this.data)); + } + + public mapErr(): Result { + return this; + } + + public isOk(): this is ResultOk { + return true; + } + + public isErr(): this is ResultErr { + return false; + } + + public unwrap(): T { + return this.data; + } +} + +export class ResultErr implements BaseResult { + constructor(public readonly data: E) {} + + public map(): Result { + return this; + } + + public mapErr(fn: (data: E) => N): Result { + return new ResultErr(fn(this.data)); + } + + public isOk(): this is ResultOk { + return false; + } + + public isErr(): this is ResultErr { + return true; + } + + public unwrap(): never { + throw new UnwrapError(this.data); + } +} + +export function Ok(data: T): Result { + return new ResultOk(data); +} + +export function Err(data: E): Result { + return new ResultErr(data); +} + +export function wrapOk(data: T | Result): Result { + if (data instanceof ResultOk || data instanceof ResultErr) { + return data; + } + + return Ok(data); +} + +export function wrapErr(data: E | Result): Result { + if (data instanceof ResultOk || data instanceof ResultErr) { + return data; + } + + return Err(data); +} + +export function isOk(result: Result): result is ResultOk { + return result.isOk(); +} + +export function isErr(result: Result): result is ResultErr { + return result.isErr(); +} + +export function flatMap(results: Result[]): Result { + const data: T[] = []; + for (const result of results) { + if (result.isErr()) { + return result; + } + + data.push(result.data); + } + + return Ok(data); +} diff --git a/tools/sourcemap-tools/tests/DebugIdGenerator.spec.ts b/tools/sourcemap-tools/tests/DebugIdGenerator.spec.ts index cb2e8b35..99c82bbd 100644 --- a/tools/sourcemap-tools/tests/DebugIdGenerator.spec.ts +++ b/tools/sourcemap-tools/tests/DebugIdGenerator.spec.ts @@ -2,7 +2,7 @@ import crypto from 'crypto'; import { DebugIdGenerator, SOURCEMAP_DEBUG_ID_KEY, SOURCE_DEBUG_ID_COMMENT, SOURCE_DEBUG_ID_VARIABLE } from '../src'; describe('DebugIdGenerator', () => { - describe('source snippet', () => { + describe('source snippet generation', () => { /** * Makes the `global` variable `undefined` in the callback. * @param callback @@ -118,7 +118,7 @@ describe('DebugIdGenerator', () => { }); }); - describe('source comment', () => { + describe('source comment generation', () => { it('should return a comment matching regex', () => { const regex = new RegExp(`^//# ${SOURCE_DEBUG_ID_COMMENT}=[a-fA-F0-9-]{36}$`); @@ -145,10 +145,38 @@ describe('DebugIdGenerator', () => { }); }); - describe('source map', () => { + describe('source comment get', () => { + it('should return debugId from source with valid comment', () => { + const expected = crypto.randomUUID(); + const source = [ + 'foo', + 'bar', + `//# ${SOURCE_DEBUG_ID_COMMENT}=${expected}`, + `//# sourceMappingURL=baz.js`, + ].join('\n'); + + const debugIdGenerator = new DebugIdGenerator(); + const actual = debugIdGenerator.getSourceDebugId(source); + + expect(actual).toEqual(expected); + }); + + it('should return undefined from source without valid comment', () => { + const source = ['foo', 'bar', `//# otherStuff=${crypto.randomUUID()}`, `//# sourceMappingURL=baz.js`].join( + '\n', + ); + + const debugIdGenerator = new DebugIdGenerator(); + const actual = debugIdGenerator.getSourceDebugId(source); + + expect(actual).toBeUndefined(); + }); + }); + + describe('source map add', () => { it('should add key to object', () => { const debugIdGenerator = new DebugIdGenerator(); - const actual = debugIdGenerator.addSourceMapKey({}, crypto.randomUUID()); + const actual = debugIdGenerator.addSourceMapDebugId({}, crypto.randomUUID()); expect(Object.keys(actual)).toContain(SOURCEMAP_DEBUG_ID_KEY); }); @@ -157,7 +185,7 @@ describe('DebugIdGenerator', () => { const expected = crypto.randomUUID(); const debugIdGenerator = new DebugIdGenerator(); - const actual = debugIdGenerator.addSourceMapKey({}, expected); + const actual = debugIdGenerator.addSourceMapDebugId({}, expected); expect(actual[SOURCEMAP_DEBUG_ID_KEY as never]).toEqual(expected); }); @@ -166,7 +194,7 @@ describe('DebugIdGenerator', () => { const expected = {}; const debugIdGenerator = new DebugIdGenerator(); - const actual = debugIdGenerator.addSourceMapKey(expected, crypto.randomUUID()); + const actual = debugIdGenerator.addSourceMapDebugId(expected, crypto.randomUUID()); expect(actual).not.toBe(expected); }); @@ -176,9 +204,34 @@ describe('DebugIdGenerator', () => { const actual = {}; const debugIdGenerator = new DebugIdGenerator(); - debugIdGenerator.addSourceMapKey(actual, crypto.randomUUID()); + debugIdGenerator.addSourceMapDebugId(actual, crypto.randomUUID()); expect(actual).toEqual(expected); }); }); + + describe('source map get', () => { + it('should return debugId from sourcemap with key', () => { + const expected = crypto.randomUUID(); + const sourcemap = { + [SOURCEMAP_DEBUG_ID_KEY]: expected, + }; + + const debugIdGenerator = new DebugIdGenerator(); + const actual = debugIdGenerator.getSourceMapDebugId(sourcemap); + + expect(actual).toEqual(expected); + }); + + it('should return undefined from sourcemap without key', () => { + const sourcemap = { + 'some-other-key': crypto.randomUUID(), + }; + + const debugIdGenerator = new DebugIdGenerator(); + const actual = debugIdGenerator.getSourceMapDebugId(sourcemap); + + expect(actual).toBeUndefined(); + }); + }); }); diff --git a/tools/sourcemap-tools/tests/FileFinder.spec.ts b/tools/sourcemap-tools/tests/FileFinder.spec.ts new file mode 100644 index 00000000..b4f99757 --- /dev/null +++ b/tools/sourcemap-tools/tests/FileFinder.spec.ts @@ -0,0 +1,63 @@ +import assert from 'assert'; +import path from 'path'; +import { FileFinder } from '../src'; + +describe('FileFinder', () => { + it('should return files in directory', async () => { + const finder = new FileFinder(); + + const result = await finder.find(path.join(__dirname, './testFiles')); + assert(result.isOk()); + + expect(result.data).toContain(path.resolve(__dirname, './testFiles', 'source.js')); + expect(result.data).toContain(path.resolve(__dirname, './testFiles', 'source.js.map')); + }); + + it('should return matching files in directory', async () => { + const finder = new FileFinder(); + + const result = await finder.find(path.join(__dirname, './testFiles'), { match: /\.map$/ }); + assert(result.isOk()); + + expect(result.data).toContain(path.resolve(__dirname, './testFiles', 'source.js.map')); + expect(result.data).not.toContain(expect.not.stringMatching(/\.map$/)); + }); + + it('should return files in subdirectories in recursive mode', async () => { + const finder = new FileFinder(); + + const result = await finder.find(path.join(__dirname, './'), { recursive: true }); + assert(result.isOk()); + + expect(result.data).toContain(path.resolve(__dirname, './testFiles', 'source.js')); + expect(result.data).toContain(path.resolve(__dirname, './testFiles', 'source.js.map')); + }); + + it('should not return files in subdirectories in non recursive mode', async () => { + const finder = new FileFinder(); + + const result = await finder.find(path.join(__dirname, './')); + assert(result.isOk()); + + expect(result.data).not.toContain(path.resolve(__dirname, './testFiles', 'source.js')); + expect(result.data).not.toContain(path.resolve(__dirname, './testFiles', 'source.js.map')); + }); + + it('should not return directories', async () => { + const finder = new FileFinder(); + + const result = await finder.find(path.join(__dirname, './')); + assert(result.isOk()); + + expect(result.data).not.toContain(path.resolve(__dirname, './testFiles')); + }); + + it('should not return directories in recursive mode', async () => { + const finder = new FileFinder(); + + const result = await finder.find(path.join(__dirname, './'), { recursive: true }); + assert(result.isOk()); + + expect(result.data).not.toContain(path.resolve(__dirname, './testFiles')); + }); +}); diff --git a/tools/sourcemap-tools/tests/SourceProcessor.spec.ts b/tools/sourcemap-tools/tests/SourceProcessor.spec.ts index 7aa04c86..36a9fdc3 100644 --- a/tools/sourcemap-tools/tests/SourceProcessor.spec.ts +++ b/tools/sourcemap-tools/tests/SourceProcessor.spec.ts @@ -1,5 +1,8 @@ -import { SourceMapConsumer } from 'source-map'; -import { DebugIdGenerator, SOURCEMAP_DEBUG_ID_KEY, SourceProcessor } from '../src'; +import assert from 'assert'; +import fs from 'fs'; +import path from 'path'; +import { RawSourceMap, SourceMapConsumer } from 'source-map'; +import { DebugIdGenerator, Ok, SOURCEMAP_DEBUG_ID_KEY, SourceProcessor } from '../src'; describe('SourceProcessor', () => { const source = `function foo(){console.log("Hello World!")}foo();`; @@ -11,61 +14,147 @@ describe('SourceProcessor', () => { mappings: 'AAAA,SAASA,MACLC,QAAQC,IAAI,cAAc,CAC9B,CAEAF,IAAI', }; - it('should append source snippet to the source on the first line', async () => { - const expected = 'APPENDED_SOURCE'; - const debugIdGenerator = new DebugIdGenerator(); + describe('processSourceAndSourceMap', () => { + it('should append source snippet to the source on the first line', async () => { + const expected = 'APPENDED_SOURCE'; + const debugIdGenerator = new DebugIdGenerator(); - jest.spyOn(debugIdGenerator, 'generateSourceSnippet').mockReturnValue(expected); + jest.spyOn(debugIdGenerator, 'generateSourceSnippet').mockReturnValue(expected); - const sourceProcessor = new SourceProcessor(debugIdGenerator); - const result = await sourceProcessor.processSourceAndSourceMap(source, sourceMap); + const sourceProcessor = new SourceProcessor(debugIdGenerator); + const result = await sourceProcessor.processSourceAndSourceMap(source, sourceMap); - expect(result.source).toMatch(new RegExp(`^${expected}\n`)); - }); + assert(result.isOk()); + expect(result.data.source).toMatch(new RegExp(`^${expected}\n`)); + }); - it('should append comment snippet to the source on the last line', async () => { - const expected = 'APPENDED_COMMENT'; - const debugIdGenerator = new DebugIdGenerator(); + it('should append comment snippet to the source on the last line', async () => { + const expected = 'APPENDED_COMMENT'; + const debugIdGenerator = new DebugIdGenerator(); - jest.spyOn(debugIdGenerator, 'generateSourceComment').mockReturnValue(expected); + jest.spyOn(debugIdGenerator, 'generateSourceComment').mockReturnValue(expected); - const sourceProcessor = new SourceProcessor(debugIdGenerator); - const result = await sourceProcessor.processSourceAndSourceMap(source, sourceMap); + const sourceProcessor = new SourceProcessor(debugIdGenerator); + const result = await sourceProcessor.processSourceAndSourceMap(source, sourceMap); - expect(result.source).toMatch(new RegExp(`\n${expected}$`)); - }); + assert(result.isOk()); + expect(result.data.source).toMatch(new RegExp(`\n${expected}$`)); + }); - it('should return sourcemap from DebugIdGenerator', async () => { - const expected = { [SOURCEMAP_DEBUG_ID_KEY]: 'debugId' }; - const debugIdGenerator = new DebugIdGenerator(); + it('should return sourcemap from DebugIdGenerator', async () => { + const expected = { [SOURCEMAP_DEBUG_ID_KEY]: 'debugId' }; + const debugIdGenerator = new DebugIdGenerator(); - jest.spyOn(debugIdGenerator, 'addSourceMapKey').mockReturnValue(expected); + jest.spyOn(debugIdGenerator, 'addSourceMapDebugId').mockReturnValue(expected); - const sourceProcessor = new SourceProcessor(debugIdGenerator); - const result = await sourceProcessor.processSourceAndSourceMap(source, sourceMap); + const sourceProcessor = new SourceProcessor(debugIdGenerator); + const result = await sourceProcessor.processSourceAndSourceMap(source, sourceMap); - expect(result.sourceMap).toStrictEqual(expected); - }); + assert(result.isOk()); + expect(result.data.sourceMap).toStrictEqual(expected); + }); - it('should offset sourcemap lines by number of newlines in source snippet + 1', 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; + it('should offset sourcemap lines by number of newlines in source snippet + 1', 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); + jest.spyOn(debugIdGenerator, 'generateSourceSnippet').mockReturnValue(snippet); - const unmodifiedConsumer = await new SourceMapConsumer(sourceMap); - const expectedPosition = unmodifiedConsumer.originalPositionFor({ line: 1, column: source.indexOf('foo();') }); + const unmodifiedConsumer = await new SourceMapConsumer(sourceMap); + const expectedPosition = unmodifiedConsumer.originalPositionFor({ + line: 1, + column: source.indexOf('foo();'), + }); - const result = await sourceProcessor.processSourceAndSourceMap(source, sourceMap); + const result = await sourceProcessor.processSourceAndSourceMap(source, sourceMap); + assert(result.isOk()); - const modifiedConsumer = await new SourceMapConsumer(result.sourceMap); - const actualPosition = modifiedConsumer.originalPositionFor({ - line: 1 + expectedNewLineCount, - column: source.indexOf('foo();'), + 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'); + const sourceContent = await fs.promises.readFile(sourcePath, 'utf-8'); + const sourceMapContent = await fs.promises.readFile(sourceMapPath, 'utf-8'); + const debugId = 'DEBUG_ID'; + + const sourceProcessor = new SourceProcessor(new DebugIdGenerator()); + const processFn = jest + .spyOn(sourceProcessor, 'processSourceAndSourceMap') + .mockImplementation(async (_, __, debugId) => + Ok({ + source: sourceContent, + sourceMap: JSON.parse(sourceMapContent), + debugId: debugId ?? 'debugId', + }), + ); + + await sourceProcessor.processSourceAndSourceMapFiles(sourcePath, sourceMapPath, debugId); + + expect(processFn).toBeCalledWith(sourceContent, sourceMapContent, debugId); }); - expect(actualPosition).toEqual(expectedPosition); + it('should call process function with sourcemap detected from source', async () => { + const sourcePath = path.join(__dirname, './testFiles/source.js'); + const sourceMapPath = path.join(__dirname, './testFiles/source.js.map'); + const sourceContent = await fs.promises.readFile(sourcePath, 'utf-8'); + const sourceMapContent = await fs.promises.readFile(sourceMapPath, 'utf-8'); + const debugId = 'DEBUG_ID'; + + const sourceProcessor = new SourceProcessor(new DebugIdGenerator()); + const processFn = jest + .spyOn(sourceProcessor, 'processSourceAndSourceMap') + .mockImplementation(async (_, __, debugId) => + Ok({ + source: sourceContent, + sourceMap: JSON.parse(sourceMapContent), + debugId: debugId ?? 'debugId', + }), + ); + + await sourceProcessor.processSourceAndSourceMapFiles(sourcePath, undefined, debugId); + + expect(processFn).toBeCalledWith(sourceContent, sourceMapContent, debugId); + }); + }); + + describe('addSourcesToSourceMap', () => { + it('should add original sources to source map', async () => { + const originalSourcePath = path.join(__dirname, './testFiles/source.ts'); + const sourceMapPath = path.join(__dirname, './testFiles/source_no_content.js.map'); + + const sourceContent = await fs.promises.readFile(originalSourcePath, 'utf-8'); + const sourceMapContent = await fs.promises.readFile(sourceMapPath, 'utf-8'); + + const sourceProcessor = new SourceProcessor(new DebugIdGenerator()); + const result = await sourceProcessor.addSourcesToSourceMap(sourceMapContent, sourceMapPath); + assert(result.isOk()); + + expect(result.data.sourcesContent).toEqual([sourceContent]); + }); + + it('should overwrite sources in source map', async () => { + const originalSourcePath = path.join(__dirname, './testFiles/source.ts'); + const sourceMapPath = path.join(__dirname, './testFiles/source.js.map'); + + const sourceContent = await fs.promises.readFile(originalSourcePath, 'utf-8'); + const sourceMapContent = JSON.parse(await fs.promises.readFile(sourceMapPath, 'utf-8')) as RawSourceMap; + sourceMapContent.sourcesContent = ['abc']; + + const sourceProcessor = new SourceProcessor(new DebugIdGenerator()); + const result = await sourceProcessor.addSourcesToSourceMap(sourceMapContent, sourceMapPath); + assert(result.isOk()); + + expect(result.data.sourcesContent).toEqual([sourceContent]); + }); }); }); diff --git a/tools/sourcemap-tools/tests/SymbolUploader.spec.ts b/tools/sourcemap-tools/tests/SymbolUploader.spec.ts index e548ef1a..a4f56094 100644 --- a/tools/sourcemap-tools/tests/SymbolUploader.spec.ts +++ b/tools/sourcemap-tools/tests/SymbolUploader.spec.ts @@ -1,4 +1,4 @@ -import { fail } from 'assert'; +import assert from 'assert'; import crypto from 'crypto'; import fs from 'fs'; import nock from 'nock'; @@ -53,7 +53,7 @@ describe('SymbolUploader', () => { }); it('should upload stream as POST body', async () => { - const sourcemapPath = path.join(__dirname, './testFiles/sourcemap.js.map'); + const sourcemapPath = path.join(__dirname, './testFiles/source.js.map'); const uploadData = await fs.promises.readFile(sourcemapPath, 'utf-8'); const stream = fs.createReadStream(sourcemapPath); const uploadUrl = new URL(`http://upload-test/`); @@ -77,26 +77,28 @@ describe('SymbolUploader', () => { const scope = nock(uploadUrl.origin).post('/').query(true).reply(200, { response: 'ok', _rxid: expected }); const uploader = new SymbolUploader(uploadUrl); - const response = await uploader.uploadSymbol(uploadData); + const result = await uploader.uploadSymbol(uploadData); + assert(result.isOk()); scope.done(); - expect(response.rxid).toEqual(expected); + expect(result.data.rxid).toEqual(expected); }); - it('should throw on non 2xx HTTP response', async () => { + it('should return Err on non 2xx HTTP response', async () => { const uploadData = getReadable(); const uploadUrl = new URL(`https://upload-test/`); const scope = nock(uploadUrl.origin).post('/').query(true).reply(400); const uploader = new SymbolUploader(uploadUrl); - await expect(() => uploader.uploadSymbol(uploadData)).rejects.toThrow(); + const result = await uploader.uploadSymbol(uploadData); + expect(result.isErr()).toEqual(true); scope.done(); }); - it('should throw on non 2xx HTTP response with response data', async () => { + it('should return Err on non 2xx HTTP response with response data', async () => { const expected = 'RESPONSE FROM SERVER'; const uploadData = getReadable(); const uploadUrl = new URL(`https://upload-test/`); @@ -104,40 +106,39 @@ describe('SymbolUploader', () => { const scope = nock(uploadUrl.origin).post('/').query(true).reply(400, expected); const uploader = new SymbolUploader(uploadUrl); - try { - await uploader.uploadSymbol(uploadData); - fail(); - } catch (err) { - expect((err as Error).message).toContain(expected); - } + const result = await uploader.uploadSymbol(uploadData); + + assert(result.isErr()); + expect(result.data).toContain(expected); scope.done(); }); - it('should throw on response with response not equal to "ok"', async () => { + it('should return Err on response with response not equal to "ok"', async () => { const uploadData = getReadable(); const uploadUrl = new URL(`https://upload-test/`); const scope = nock(uploadUrl.origin).post('/').query(true).reply(200, { response: 'not-ok', _rxid: 'rxid' }); const uploader = new SymbolUploader(uploadUrl); - await expect(() => uploader.uploadSymbol(uploadData)).rejects.toThrow(); + const result = await uploader.uploadSymbol(uploadData); + + expect(result.isErr()).toEqual(true); scope.done(); }); - it('should throw on response with response not equal to "ok" with response data', async () => { + it('should return Err on response with response not equal to "ok" with response data', async () => { const expected = JSON.stringify({ response: 'not-ok', _rxid: 'rxid' }); const uploadData = getReadable(); const uploadUrl = new URL(`https://upload-test/`); const scope = nock(uploadUrl.origin).post('/').query(true).reply(200, expected); const uploader = new SymbolUploader(uploadUrl); - try { - await uploader.uploadSymbol(uploadData); - fail(); - } catch (err) { - expect((err as Error).message).toContain(expected); - } + + const result = await uploader.uploadSymbol(uploadData); + + assert(result.isErr()); + expect(result.data).toContain(expected); scope.done(); }); diff --git a/tools/sourcemap-tools/tests/testFiles/source.js b/tools/sourcemap-tools/tests/testFiles/source.js new file mode 100644 index 00000000..8666563f --- /dev/null +++ b/tools/sourcemap-tools/tests/testFiles/source.js @@ -0,0 +1,2 @@ +(()=>{"use strict";console.log("Hello World!")})(); +//# sourceMappingURL=source.js.map \ No newline at end of file diff --git a/tools/sourcemap-tools/tests/testFiles/source.js.map b/tools/sourcemap-tools/tests/testFiles/source.js.map new file mode 100644 index 00000000..0efa14bc --- /dev/null +++ b/tools/sourcemap-tools/tests/testFiles/source.js.map @@ -0,0 +1 @@ +{"version":3,"file":"source.js","mappings":";mBAAAA,QAAQC,IAAI,e","sources":["./source.ts"],"sourcesContent":["console.log('Hello World!');\n"],"names":["console","log"],"sourceRoot":""} \ No newline at end of file diff --git a/tools/sourcemap-tools/tests/testFiles/source.ts b/tools/sourcemap-tools/tests/testFiles/source.ts new file mode 100644 index 00000000..a420803c --- /dev/null +++ b/tools/sourcemap-tools/tests/testFiles/source.ts @@ -0,0 +1 @@ +console.log('Hello World!'); diff --git a/tools/sourcemap-tools/tests/testFiles/source_no_content.js.map b/tools/sourcemap-tools/tests/testFiles/source_no_content.js.map new file mode 100644 index 00000000..8e67cf4b --- /dev/null +++ b/tools/sourcemap-tools/tests/testFiles/source_no_content.js.map @@ -0,0 +1 @@ +{"version":3,"file":"source.js","mappings":";mBAAAA,QAAQC,IAAI,e","sources":["./source.ts"],"names":["console","log"],"sourceRoot":""} \ No newline at end of file diff --git a/tools/sourcemap-tools/tests/testFiles/sourcemap.js.map b/tools/sourcemap-tools/tests/testFiles/sourcemap.js.map deleted file mode 100644 index 6d5815f8..00000000 --- a/tools/sourcemap-tools/tests/testFiles/sourcemap.js.map +++ /dev/null @@ -1 +0,0 @@ -{"version":3,"sources":["webpack:///webpack/bootstrap","webpack:///./tests/e2e/single-input-single-output/input/index.ts","webpack:///./tests/e2e/single-input-single-output/input/dependency.ts"],"names":["installedModules","__webpack_require__","moduleId","exports","module","i","l","modules","call","m","c","d","name","getter","o","Object","defineProperty","enumerable","get","r","Symbol","toStringTag","value","t","mode","__esModule","ns","create","key","bind","n","object","property","prototype","hasOwnProperty","p","s","dependency_1","console","log","doSomething"],"mappings":"aACE,IAAIA,EAAmB,GAGvB,SAASC,EAAoBC,GAG5B,GAAGF,EAAiBE,GACnB,OAAOF,EAAiBE,GAAUC,QAGnC,IAAIC,EAASJ,EAAiBE,GAAY,CACzCG,EAAGH,EACHI,GAAG,EACHH,QAAS,IAUV,OANAI,EAAQL,GAAUM,KAAKJ,EAAOD,QAASC,EAAQA,EAAOD,QAASF,GAG/DG,EAAOE,GAAI,EAGJF,EAAOD,QAKfF,EAAoBQ,EAAIF,EAGxBN,EAAoBS,EAAIV,EAGxBC,EAAoBU,EAAI,SAASR,EAASS,EAAMC,GAC3CZ,EAAoBa,EAAEX,EAASS,IAClCG,OAAOC,eAAeb,EAASS,EAAM,CAAEK,YAAY,EAAMC,IAAKL,KAKhEZ,EAAoBkB,EAAI,SAAShB,GACX,oBAAXiB,QAA0BA,OAAOC,aAC1CN,OAAOC,eAAeb,EAASiB,OAAOC,YAAa,CAAEC,MAAO,WAE7DP,OAAOC,eAAeb,EAAS,aAAc,CAAEmB,OAAO,KAQvDrB,EAAoBsB,EAAI,SAASD,EAAOE,GAEvC,GADU,EAAPA,IAAUF,EAAQrB,EAAoBqB,IAC/B,EAAPE,EAAU,OAAOF,EACpB,GAAW,EAAPE,GAA8B,iBAAVF,GAAsBA,GAASA,EAAMG,WAAY,OAAOH,EAChF,IAAII,EAAKX,OAAOY,OAAO,MAGvB,GAFA1B,EAAoBkB,EAAEO,GACtBX,OAAOC,eAAeU,EAAI,UAAW,CAAET,YAAY,EAAMK,MAAOA,IACtD,EAAPE,GAA4B,iBAATF,EAAmB,IAAI,IAAIM,KAAON,EAAOrB,EAAoBU,EAAEe,EAAIE,EAAK,SAASA,GAAO,OAAON,EAAMM,IAAQC,KAAK,KAAMD,IAC9I,OAAOF,GAIRzB,EAAoB6B,EAAI,SAAS1B,GAChC,IAAIS,EAAST,GAAUA,EAAOqB,WAC7B,WAAwB,OAAOrB,EAAgB,SAC/C,WAA8B,OAAOA,GAEtC,OADAH,EAAoBU,EAAEE,EAAQ,IAAKA,GAC5BA,GAIRZ,EAAoBa,EAAI,SAASiB,EAAQC,GAAY,OAAOjB,OAAOkB,UAAUC,eAAe1B,KAAKuB,EAAQC,IAGzG/B,EAAoBkC,EAAI,GAIjBlC,EAAoBA,EAAoBmC,EAAI,G,+BCjFrDrB,OAAOC,eAAeb,EAAS,aAAc,CAAEmB,OAAO,IACtD,MAAMe,EAAe,EAAQ,GAC7BC,QAAQC,IAAI,iBACZ,EAAIF,EAAaG,gB,6BCHjBzB,OAAOC,eAAeb,EAAS,aAAc,CAAEmB,OAAO,IACtDnB,EAAQqC,iBAAc,EAItBrC,EAAQqC,YAHR,WACIF,QAAQC,IAAI","file":"main.js","sourcesContent":[" \t// The module cache\n \tvar installedModules = {};\n\n \t// The require function\n \tfunction __webpack_require__(moduleId) {\n\n \t\t// Check if module is in cache\n \t\tif(installedModules[moduleId]) {\n \t\t\treturn installedModules[moduleId].exports;\n \t\t}\n \t\t// Create a new module (and put it into the cache)\n \t\tvar module = installedModules[moduleId] = {\n \t\t\ti: moduleId,\n \t\t\tl: false,\n \t\t\texports: {}\n \t\t};\n\n \t\t// Execute the module function\n \t\tmodules[moduleId].call(module.exports, module, module.exports, __webpack_require__);\n\n \t\t// Flag the module as loaded\n \t\tmodule.l = true;\n\n \t\t// Return the exports of the module\n \t\treturn module.exports;\n \t}\n\n\n \t// expose the modules object (__webpack_modules__)\n \t__webpack_require__.m = modules;\n\n \t// expose the module cache\n \t__webpack_require__.c = installedModules;\n\n \t// define getter function for harmony exports\n \t__webpack_require__.d = function(exports, name, getter) {\n \t\tif(!__webpack_require__.o(exports, name)) {\n \t\t\tObject.defineProperty(exports, name, { enumerable: true, get: getter });\n \t\t}\n \t};\n\n \t// define __esModule on exports\n \t__webpack_require__.r = function(exports) {\n \t\tif(typeof Symbol !== 'undefined' && Symbol.toStringTag) {\n \t\t\tObject.defineProperty(exports, Symbol.toStringTag, { value: 'Module' });\n \t\t}\n \t\tObject.defineProperty(exports, '__esModule', { value: true });\n \t};\n\n \t// create a fake namespace object\n \t// mode & 1: value is a module id, require it\n \t// mode & 2: merge all properties of value into the ns\n \t// mode & 4: return value when already ns object\n \t// mode & 8|1: behave like require\n \t__webpack_require__.t = function(value, mode) {\n \t\tif(mode & 1) value = __webpack_require__(value);\n \t\tif(mode & 8) return value;\n \t\tif((mode & 4) && typeof value === 'object' && value && value.__esModule) return value;\n \t\tvar ns = Object.create(null);\n \t\t__webpack_require__.r(ns);\n \t\tObject.defineProperty(ns, 'default', { enumerable: true, value: value });\n \t\tif(mode & 2 && typeof value != 'string') for(var key in value) __webpack_require__.d(ns, key, function(key) { return value[key]; }.bind(null, key));\n \t\treturn ns;\n \t};\n\n \t// getDefaultExport function for compatibility with non-harmony modules\n \t__webpack_require__.n = function(module) {\n \t\tvar getter = module && module.__esModule ?\n \t\t\tfunction getDefault() { return module['default']; } :\n \t\t\tfunction getModuleExports() { return module; };\n \t\t__webpack_require__.d(getter, 'a', getter);\n \t\treturn getter;\n \t};\n\n \t// Object.prototype.hasOwnProperty.call\n \t__webpack_require__.o = function(object, property) { return Object.prototype.hasOwnProperty.call(object, property); };\n\n \t// __webpack_public_path__\n \t__webpack_require__.p = \"\";\n\n\n \t// Load entry module and return exports\n \treturn __webpack_require__(__webpack_require__.s = 0);\n","\"use strict\";\nObject.defineProperty(exports, \"__esModule\", { value: true });\nconst dependency_1 = require(\"./dependency\");\nconsole.log('Hello World!');\n(0, dependency_1.doSomething)();\n","\"use strict\";\nObject.defineProperty(exports, \"__esModule\", { value: true });\nexports.doSomething = void 0;\nfunction doSomething() {\n console.log('Done something');\n}\nexports.doSomething = doSomething;\n"],"sourceRoot":"","debugId":"251a0026-7fbb-43a3-bce6-690dad9f8d58"} \ No newline at end of file diff --git a/tools/webpack-plugin/src/BacktracePlugin.ts b/tools/webpack-plugin/src/BacktracePlugin.ts index 386a595e..cea651d8 100644 --- a/tools/webpack-plugin/src/BacktracePlugin.ts +++ b/tools/webpack-plugin/src/BacktracePlugin.ts @@ -56,7 +56,21 @@ export class BacktracePlugin implements WebpackPluginInstance { logger.time(`[${asset}] process source and sourcemap`); try { - debugId = await this._sourceProcessor.processSourceAndSourceMapFiles(sourcePath, sourceMapPath); + const result = await this._sourceProcessor.processSourceAndSourceMapFiles( + sourcePath, + sourceMapPath, + ); + + if (result.isErr()) { + logger.error(`[${asset}] process source and sourcemap failed:`, result.data); + processResults.set(asset, new Error(result.data)); + continue; + } + + debugId = result.data.debugId; + await fs.promises.writeFile(sourcePath, result.data.source, 'utf8'); + await fs.promises.writeFile(sourceMapPath, JSON.stringify(result.data.sourceMap), 'utf8'); + processResults.set(asset, debugId); } catch (err) { logger.error(`[${asset}] process source and sourcemap failed:`, err); @@ -80,7 +94,12 @@ export class BacktracePlugin implements WebpackPluginInstance { await archive.finalize(); const result = await request; - uploadResult = result.rxid; + if (result.isErr()) { + logger.error(`upload sourcemaps failed:`, result.data); + uploadResult = new Error(result.data); + } else { + uploadResult = result.data.rxid; + } } catch (err) { logger.error(`upload sourcemaps failed:`, err); uploadResult = err instanceof Error ? err : new Error('Unknown error.'); diff --git a/tools/webpack-plugin/tests/e2e/createE2ETest.ts b/tools/webpack-plugin/tests/e2e/createE2ETest.ts index 5dd9c230..242bd4af 100644 --- a/tools/webpack-plugin/tests/e2e/createE2ETest.ts +++ b/tools/webpack-plugin/tests/e2e/createE2ETest.ts @@ -1,5 +1,6 @@ -import { SourceProcessor, SymbolUploader } from '@backtrace/sourcemap-tools'; +import { Ok, SourceProcessor, SymbolUploader, ZipArchive } from '@backtrace/sourcemap-tools'; import assert from 'assert'; +import crypto from 'crypto'; import fs from 'fs'; import path from 'path'; import webpack from 'webpack'; @@ -8,29 +9,36 @@ import { asyncWebpack, expectSuccess, getFiles, removeDir, webpackModeTest } fro export function createE2ETest(configBuilder: (mode: webpack.Configuration['mode']) => webpack.Configuration) { webpackModeTest((mode) => { function mockUploader() { - return jest.spyOn(SymbolUploader.prototype, 'upload').mockImplementation((_, debugId) => - Promise.resolve({ - debugId: debugId ?? crypto.randomUUID(), - rxid: crypto.randomUUID(), - }), + return jest.spyOn(SymbolUploader.prototype, 'uploadSymbol').mockImplementation(() => + Promise.resolve( + Ok({ + rxid: crypto.randomUUID(), + }), + ), ); } function mockProcessor() { return jest .spyOn(SourceProcessor.prototype, 'processSourceAndSourceMapFiles') - .mockImplementation(async (_, __, debugId) => debugId ?? 'debugId'); + .mockImplementation(async (_, __, debugId) => Ok({ debugId: debugId ?? 'debugId' } as never)); + } + + function mockZipArchiveAppend() { + return jest.spyOn(ZipArchive.prototype, 'append').mockReturnThis(); } let result: webpack.Stats; let uploadSpy: ReturnType; let processSpy: ReturnType; + let zipArchiveAppendSpy: ReturnType; beforeAll(async () => { jest.resetAllMocks(); uploadSpy = mockUploader(); processSpy = mockProcessor(); + zipArchiveAppendSpy = mockZipArchiveAppend(); const config = configBuilder(mode); if (config.output?.path) { @@ -50,7 +58,7 @@ export function createE2ETest(configBuilder: (mode: webpack.Configuration['mode' expect(jsFiles.length).toBeGreaterThan(0); const processedPairs = processSpy.mock.calls.map( - ([p1, p2]) => [path.resolve(p1), path.resolve(p2)] as const, + ([p1, p2]) => [path.resolve(p1), p2 ? path.resolve(p2) : undefined] as const, ); for (const file of jsFiles) { const content = await fs.promises.readFile(file, 'utf8'); @@ -65,17 +73,21 @@ export function createE2ETest(configBuilder: (mode: webpack.Configuration['mode' } }); - it('should call SourceMapUploader for every emitted sourcemap', async () => { + it('should append every emitted sourcemap to archive', async () => { const outputDir = result.compilation.outputOptions.path; assert(outputDir); const mapFiles = await getFiles(outputDir, /.js.map$/); expect(mapFiles.length).toBeGreaterThan(0); - const uploadedFiles = uploadSpy.mock.calls.map((c) => path.resolve(c[0])); + const uploadedFiles = zipArchiveAppendSpy.mock.calls.map((c) => path.resolve(c[1] as string)); for (const file of mapFiles) { expect(uploadedFiles).toContain(path.resolve(file)); } }); + + it('should upload archive', async () => { + expect(uploadSpy).toBeCalledWith(expect.any(ZipArchive)); + }); }); }