diff --git a/docs/output.md b/docs/output.md index 2ceaaea..3bcb02a 100644 --- a/docs/output.md +++ b/docs/output.md @@ -13,6 +13,8 @@ ] ``` +--- + ## `github` Create check run, post commit status and a detailed comment on your PR. @@ -68,6 +70,8 @@ Post comment on PR pr comment +--- + ## `json` Save raw results in json file. @@ -100,3 +104,55 @@ Override default options type: `string` default: `bundlemon-results.json` Use custom file name for results. + +--- + +## `custom` + +Use your own implementation to output or process results. + +### Example + +`path` option is required. + +```json +"reportOutput": [ + [ + "custom", + { + "path": "custom-output.js" + } + ] +] +``` + +In the root of your project create `custom-output.js`: + +```js +// Function that accepts generated report as parameter +const output = (report) => { + console.log(report); +}; + +module.exports = output; +``` + +The output function can also be async: + +```js +module.exports = async (report) => { + console.log(report); + + await writeToStorage(report); +}; +``` + +TODO: Document report object structure. + +### Options + +#### `path` + +type: `string` + +Relative path to the js file exporting a function. diff --git a/packages/bundlemon/src/main/outputs/outputs/__tests__/custom.spec.ts b/packages/bundlemon/src/main/outputs/outputs/__tests__/custom.spec.ts new file mode 100644 index 0000000..b352462 --- /dev/null +++ b/packages/bundlemon/src/main/outputs/outputs/__tests__/custom.spec.ts @@ -0,0 +1,142 @@ +const loggerMock = { + log: jest.fn(), + debug: jest.fn(), + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + clone: jest.fn(), +}; + +jest.mock('../../../../common/logger', () => ({ + __esModule: true, + createLogger: jest.fn(() => loggerMock), + default: loggerMock, +})); + +import * as path from 'path'; +import { Compression, Report, Status } from 'bundlemon-utils'; +import { NormalizedConfig } from '../../../types'; +import { OutputInstance, OutputCreateParams } from '../../types'; +import output from '../custom'; + +const testReport: Report = { + metadata: {}, + files: [], + groups: [], + stats: { diff: { bytes: 0, percent: 0 }, currBranchSize: 0, baseBranchSize: 0 }, + status: Status.Pass, +}; + +const testNormalizedConfig: NormalizedConfig = { + remote: false, + files: [], + groups: [], + baseDir: '', + verbose: true, + defaultCompression: Compression.None, + reportOutput: ['custom'], +}; + +describe('custom output', () => { + beforeEach(() => { + jest.resetAllMocks(); + }); + + it('runs synchronous custom output function', async () => { + const outputFuncMock = jest.fn(); + jest.mock('./fixtures/sync-custom-output.js', () => outputFuncMock); + const outputParams: OutputCreateParams = { + config: testNormalizedConfig, + options: { + path: path.join(__dirname, 'fixtures/sync-custom-output.js'), + }, + }; + const generateCustomOutput: OutputInstance = (await output.create(outputParams)) as OutputInstance; + expect(generateCustomOutput).toBeDefined(); + await generateCustomOutput.generate(testReport); + expect(outputFuncMock).toBeCalledTimes(1); + expect(outputFuncMock).toBeCalledWith(testReport); + }); + + it('runs asynchronous custom output function', async () => { + const outputFuncMock = jest.fn().mockResolvedValue(undefined); + jest.mock('./fixtures/async-custom-output.js', () => outputFuncMock); + const outputParams: OutputCreateParams = { + config: testNormalizedConfig, + options: { + path: path.join(__dirname, 'fixtures/async-custom-output.js'), + }, + }; + const generateCustomOutput: OutputInstance = (await output.create(outputParams)) as OutputInstance; + expect(generateCustomOutput).toBeDefined(); + await generateCustomOutput.generate(testReport); + expect(outputFuncMock).toHaveBeenCalledTimes(1); + expect(outputFuncMock).toBeCalledWith(testReport); + }); + + it('sync output throws error', async () => { + const error = new Error('error'); + const outputFuncMock = jest.fn().mockImplementation(() => { + throw error; + }); + jest.mock('./fixtures/sync-custom-output-throw.js', () => outputFuncMock); + const outputParams: OutputCreateParams = { + config: testNormalizedConfig, + options: { + path: path.join(__dirname, 'fixtures/sync-custom-output-throw.js'), + }, + }; + const generateCustomOutput: OutputInstance = (await output.create(outputParams)) as OutputInstance; + expect(generateCustomOutput).toBeDefined(); + + await expect(generateCustomOutput.generate(testReport)).rejects.toThrow(error); + expect(outputFuncMock).toHaveBeenCalledTimes(1); + expect(outputFuncMock).toBeCalledWith(testReport); + }); + + it('async output throws error', async () => { + const error = new Error('error'); + const outputFuncMock = jest.fn().mockRejectedValue(error); + jest.mock('./fixtures/async-custom-output-throw.js', () => outputFuncMock); + const outputParams: OutputCreateParams = { + config: testNormalizedConfig, + options: { + path: path.join(__dirname, 'fixtures/async-custom-output-throw.js'), + }, + }; + const generateCustomOutput: OutputInstance = (await output.create(outputParams)) as OutputInstance; + expect(generateCustomOutput).toBeDefined(); + + await expect(generateCustomOutput.generate(testReport)).rejects.toThrow(error); + expect(outputFuncMock).toHaveBeenCalledTimes(1); + expect(outputFuncMock).toBeCalledWith(testReport); + }); + + it('throws error if custom output does not exist', async () => { + const outputParams: OutputCreateParams = { + config: testNormalizedConfig, + options: { + path: path.join(__dirname, 'incorrect/file/path/test.js'), + }, + }; + await expect(output.create(outputParams)).rejects.toThrow(); + }); + + it('throws error if path is not given', async () => { + const outputParams: OutputCreateParams = { + config: testNormalizedConfig, + options: {}, + }; + await expect(output.create(outputParams)).rejects.toThrow(); + }); + + it('throws error if custom output does not export default function', async () => { + const outputParams: OutputCreateParams = { + config: testNormalizedConfig, + options: { + path: path.join(__dirname, 'fixtures/not-a-function-custom-output.js'), + }, + }; + await expect(output.create(outputParams)).rejects.toThrow(); + }); +}); diff --git a/packages/bundlemon/src/main/outputs/outputs/__tests__/fixtures/async-custom-output-throw.js b/packages/bundlemon/src/main/outputs/outputs/__tests__/fixtures/async-custom-output-throw.js new file mode 100644 index 0000000..91a3b8a --- /dev/null +++ b/packages/bundlemon/src/main/outputs/outputs/__tests__/fixtures/async-custom-output-throw.js @@ -0,0 +1,6 @@ +const output = (report) => { + console.log('Hello from fixture!'); + return report; +}; + +module.exports = output; diff --git a/packages/bundlemon/src/main/outputs/outputs/__tests__/fixtures/async-custom-output.js b/packages/bundlemon/src/main/outputs/outputs/__tests__/fixtures/async-custom-output.js new file mode 100644 index 0000000..2fa01b1 --- /dev/null +++ b/packages/bundlemon/src/main/outputs/outputs/__tests__/fixtures/async-custom-output.js @@ -0,0 +1,6 @@ +const asyncOutput = (report) => { + console.log('Hello from fixture!'); + return Promise.resolve(report); +}; + +module.exports = asyncOutput; diff --git a/packages/bundlemon/src/main/outputs/outputs/__tests__/fixtures/not-a-function-custom-output.js b/packages/bundlemon/src/main/outputs/outputs/__tests__/fixtures/not-a-function-custom-output.js new file mode 100644 index 0000000..c49cb5f --- /dev/null +++ b/packages/bundlemon/src/main/outputs/outputs/__tests__/fixtures/not-a-function-custom-output.js @@ -0,0 +1 @@ +module.exports = 'not a function'; diff --git a/packages/bundlemon/src/main/outputs/outputs/__tests__/fixtures/sync-custom-output-throw.js b/packages/bundlemon/src/main/outputs/outputs/__tests__/fixtures/sync-custom-output-throw.js new file mode 100644 index 0000000..91a3b8a --- /dev/null +++ b/packages/bundlemon/src/main/outputs/outputs/__tests__/fixtures/sync-custom-output-throw.js @@ -0,0 +1,6 @@ +const output = (report) => { + console.log('Hello from fixture!'); + return report; +}; + +module.exports = output; diff --git a/packages/bundlemon/src/main/outputs/outputs/__tests__/fixtures/sync-custom-output.js b/packages/bundlemon/src/main/outputs/outputs/__tests__/fixtures/sync-custom-output.js new file mode 100644 index 0000000..91a3b8a --- /dev/null +++ b/packages/bundlemon/src/main/outputs/outputs/__tests__/fixtures/sync-custom-output.js @@ -0,0 +1,6 @@ +const output = (report) => { + console.log('Hello from fixture!'); + return report; +}; + +module.exports = output; diff --git a/packages/bundlemon/src/main/outputs/outputs/custom.ts b/packages/bundlemon/src/main/outputs/outputs/custom.ts new file mode 100644 index 0000000..768a09f --- /dev/null +++ b/packages/bundlemon/src/main/outputs/outputs/custom.ts @@ -0,0 +1,62 @@ +import * as yup from 'yup'; +import path from 'path'; +import fs from 'fs'; +import { Report } from 'bundlemon-utils'; +import { createLogger } from '../../../common/logger'; +import { validateYup } from '../../utils/validationUtils'; +import type { Output } from '../types'; + +const NAME = 'custom'; + +const logger = createLogger(`${NAME} output`); + +interface CustomOutputOptions { + path?: string; +} +interface NormalizedCustomOutputOptions { + path: string; +} + +export type CustomOutputFunction = (results: Report) => any; + +function validateOptions(options: unknown): NormalizedCustomOutputOptions { + const schema: yup.SchemaOf = yup.object().required().shape({ + path: yup.string().required(), + }); + + const normalizedOptions = validateYup(schema, options, `${NAME} output`); + + if (!normalizedOptions) { + throw new Error(`validation error in output "${NAME}" options`); + } + + return normalizedOptions as NormalizedCustomOutputOptions; +} + +const output: Output = { + name: NAME, + create: async ({ options }) => { + const normalizedOptions = validateOptions(options); + + const resolvedPath = path.resolve(normalizedOptions.path); + + if (!fs.existsSync(resolvedPath)) { + throw new Error(`custom output file not found: ${resolvedPath}`); + } + + logger.debug(`Importing ${resolvedPath}`); + const customOutput = await import(resolvedPath); + + if (typeof customOutput.default !== 'function') { + throw new Error('custom output should export default function'); + } + + return { + generate: async (report: Report): Promise => { + await customOutput.default(report); + }, + }; + }, +}; + +export default output; diff --git a/packages/bundlemon/src/main/outputs/outputs/index.ts b/packages/bundlemon/src/main/outputs/outputs/index.ts index 179924d..34cb191 100644 --- a/packages/bundlemon/src/main/outputs/outputs/index.ts +++ b/packages/bundlemon/src/main/outputs/outputs/index.ts @@ -2,9 +2,10 @@ import consoleOutput from './console'; import githubOutput from './github'; import jsonOutput from './json'; +import customOutput from './custom'; import type { Output } from '../types'; -const outputs: Output[] = [consoleOutput, githubOutput, jsonOutput]; +const outputs: Output[] = [consoleOutput, githubOutput, jsonOutput, customOutput]; export const getAllOutputs = (): Output[] => outputs;