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
+---
+
## `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;