diff --git a/node-src/index.test.ts b/node-src/index.test.ts index a9f34a9c1..1e2f4f2eb 100644 --- a/node-src/index.test.ts +++ b/node-src/index.test.ts @@ -296,6 +296,9 @@ vi.mock('fs', async (importOriginal) => { if (path.endsWith('/package.json')) return fsStatSync(path); // for meow return { isDirectory: () => false, size: 42 }; }), + existsSync: vi.fn((_path) => { + return true; + }), access: vi.fn((_path, callback) => Promise.resolve(callback(undefined))), }; }); diff --git a/node-src/lib/getConfiguration.test.ts b/node-src/lib/getConfiguration.test.ts index 39ad5a6c0..5e1863565 100644 --- a/node-src/lib/getConfiguration.test.ts +++ b/node-src/lib/getConfiguration.test.ts @@ -1,16 +1,18 @@ -import { readFileSync } from 'fs'; -import { beforeEach, expect, it, vi } from 'vitest'; +import { existsSync, PathLike, readFileSync } from 'fs'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { getConfiguration } from './getConfiguration'; vi.mock('fs'); const mockedReadFile = vi.mocked(readFileSync); +const mockedExistsSync = vi.mocked(existsSync); beforeEach(() => { mockedReadFile.mockReset(); + mockedExistsSync.mockReset(); }); -it('reads configuration successfully', async () => { +it('reads basic JSON configuration successfully', async () => { mockedReadFile.mockReturnValue( JSON.stringify({ $schema: 'https://www.chromatic.com/config-file.schema.json', @@ -83,6 +85,85 @@ it('reads configuration successfully', async () => { }); }); +it('reads JSON5 configuration successfully', async () => { + mockedReadFile.mockReturnValue(` + { + "$schema": "https://www.chromatic.com/config-file.schema.json", + "projectId": "project-id", + "projectToken": "project-token", + "onlyChanged": "only-changed", + "traceChanged": "expanded", + "onlyStoryFiles": [ + "only-story-files" + ], + "onlyStoryNames": [ + "only-story-names" + ], + "untraced": [ + "untraced" + ], + "externals": [ + "externals" + ], + // This is a comment in a json file + "debug": true, + "diagnosticsFile": "diagnostics-file", + "fileHashing": true, + "junitReport": "junit-report", + "zip": true, + "autoAcceptChanges": "auto-accept-changes", + "exitZeroOnChanges": "exit-zero-on-changes", + "exitOnceUploaded": "exit-once-uploaded", + "ignoreLastBuildOnBranch": "ignore-last-build-on-branch", + "buildScriptName": "build-script-name", + "outputDir": "output-dir", + "skip": "skip", + "skipUpdateCheck": false, + "storybookBuildDir": "storybook-build-dir", + "storybookBaseDir": "storybook-base-dir", + "storybookConfigDir": "storybook-config-dir", + "storybookLogFile": "storybook-log-file", + "logFile": "log-file", + "uploadMetadata": true + } + `); + + expect(await getConfiguration()).toEqual({ + $schema: 'https://www.chromatic.com/config-file.schema.json', + configFile: 'chromatic.config.json', + projectId: 'project-id', + projectToken: 'project-token', + + onlyChanged: 'only-changed', + traceChanged: 'expanded', + onlyStoryFiles: ['only-story-files'], + onlyStoryNames: ['only-story-names'], + untraced: ['untraced'], + externals: ['externals'], + debug: true, + diagnosticsFile: 'diagnostics-file', + fileHashing: true, + junitReport: 'junit-report', + zip: true, + autoAcceptChanges: 'auto-accept-changes', + exitZeroOnChanges: 'exit-zero-on-changes', + exitOnceUploaded: 'exit-once-uploaded', + ignoreLastBuildOnBranch: 'ignore-last-build-on-branch', + + buildScriptName: 'build-script-name', + outputDir: 'output-dir', + skip: 'skip', + skipUpdateCheck: false, + + storybookBuildDir: 'storybook-build-dir', + storybookBaseDir: 'storybook-base-dir', + storybookConfigDir: 'storybook-config-dir', + storybookLogFile: 'storybook-log-file', + logFile: 'log-file', + uploadMetadata: true, + }); +}); + it('handles other side of union options', async () => { mockedReadFile.mockReturnValue( JSON.stringify({ @@ -116,65 +197,156 @@ it('handles other side of union options', async () => { }); }); -it('reads from chromatic.config.json by default', async () => { - mockedReadFile.mockReturnValue(JSON.stringify({ projectToken: 'json-file-token' })).mockClear(); - await getConfiguration(); +describe('resolveConfigFileName', () => { + describe('when no other config files exist', () => { + beforeEach(() => { + mockedExistsSync.mockImplementation((_path: PathLike) => { + return false; + }); + }); - expect(mockedReadFile).toHaveBeenCalledWith('chromatic.config.json', 'utf8'); -}); + afterEach(() => { + mockedExistsSync.mockReset(); + }); -it('can read from a different location', async () => { - mockedReadFile.mockReturnValue(JSON.stringify({ projectToken: 'json-file-token' })).mockClear(); - await getConfiguration('test.file'); + it('reads from chromatic.config.json by default', async () => { + mockedReadFile + .mockReturnValue(JSON.stringify({ projectToken: 'json-file-token' })) + .mockClear(); - expect(mockedReadFile).toHaveBeenCalledWith('test.file', 'utf8'); -}); + await getConfiguration(); -it('returns nothing if there is no config file and it was not specified', async () => { - mockedReadFile.mockImplementation(() => { - throw new Error('ENOENT'); + expect(mockedReadFile).toHaveBeenCalledWith('chromatic.config.json', 'utf8'); + }); }); - expect(await getConfiguration()).toEqual({}); -}); + describe('if the chromatic.config.jsonc file exists', () => { + beforeEach(() => { + mockedExistsSync.mockImplementation((path: PathLike) => { + if (path === 'chromatic.config.jsonc') { + return true; + } + + return false; + }); + }); + + afterEach(() => { + mockedExistsSync.mockReset(); + }); + + it('reads chromatic.config.json', async () => { + mockedReadFile + .mockReturnValue(JSON.stringify({ projectToken: 'json-file-token' })) + .mockClear(); + + await getConfiguration(); -it('returns nothing if there is no config file and it was specified', async () => { - mockedReadFile.mockImplementation(() => { - throw new Error('ENOENT'); + expect(mockedReadFile).toHaveBeenCalledWith('chromatic.config.jsonc', 'utf8'); + + mockedExistsSync.mockClear(); + }); }); - await expect(getConfiguration('test.file')).rejects.toThrow(/could not be found/); -}); + describe('if the chromatic.config.json5 file exists', () => { + beforeEach(() => { + mockedExistsSync.mockImplementation((path: PathLike) => { + if (path === 'chromatic.config.json5') { + return true; + } -it('errors if config file contains invalid data', async () => { - mockedReadFile.mockReturnValue(JSON.stringify({ projectToken: 1 })); + return false; + }); + }); - await expect(getConfiguration('test.file')).rejects.toThrow(/projectToken/); -}); + afterEach(() => { + mockedExistsSync.mockReset(); + }); -it('errors if config file contains unknown keys', async () => { - mockedReadFile.mockReturnValue(JSON.stringify({ random: 1 })); + it('reads chromatic.config.json5 if it exists', async () => { + mockedReadFile + .mockReturnValue(JSON.stringify({ projectToken: 'json-file-token' })) + .mockClear(); - await expect(getConfiguration('test.file')).rejects.toThrow(/random/); -}); + await getConfiguration(); + + expect(mockedReadFile).toHaveBeenCalledWith('chromatic.config.json5', 'utf8'); + + mockedExistsSync.mockClear(); + }); + }); + + describe('when a config file is specified and exists on the file system', () => { + beforeEach(() => { + mockedExistsSync.mockImplementation((path: PathLike) => { + if (path === 'test.file') { + return true; + } + + return false; + }); + }); -it('errors if config file is unparseable', async () => { - { - mockedReadFile.mockReturnValue('invalid json'); - await expect(getConfiguration('test.file')).rejects.toThrow( - /Configuration file .+ could not be parsed/ - ); - } - { - mockedReadFile.mockReturnValue('{ "foo": 1 "unexpectedString": 2 }'); - await expect(getConfiguration('test.file')).rejects.toThrow( - /Configuration file .+ could not be parsed/ - ); - } - { - mockedReadFile.mockReturnValue('{ "unexpectedEnd": '); - await expect(getConfiguration('test.file')).rejects.toThrow( - /Configuration file .+ could not be parsed/ - ); - } + afterEach(() => { + mockedExistsSync.mockReset(); + }); + + it('can read from that config file', async () => { + mockedReadFile + .mockReturnValue(JSON.stringify({ projectToken: 'json-file-token' })) + .mockClear(); + await getConfiguration('test.file'); + + expect(mockedReadFile).toHaveBeenCalledWith('test.file', 'utf8'); + }); + }); + + it('returns nothing if there is no config file and it was not specified', async () => { + mockedReadFile.mockImplementation(() => { + throw new Error('ENOENT'); + }); + + expect(await getConfiguration()).toEqual({}); + }); + + it('returns nothing if there is no config file and it was specified', async () => { + mockedReadFile.mockImplementation(() => { + throw new Error('ENOENT'); + }); + + await expect(getConfiguration('test.file')).rejects.toThrow(/could not be found/); + }); + + it('errors if config file contains invalid data', async () => { + mockedReadFile.mockReturnValue(JSON.stringify({ projectToken: 1 })); + + await expect(getConfiguration('test.file')).rejects.toThrow(/projectToken/); + }); + + it('errors if config file contains unknown keys', async () => { + mockedReadFile.mockReturnValue(JSON.stringify({ random: 1 })); + + await expect(getConfiguration('test.file')).rejects.toThrow(/random/); + }); + + it('errors if config file is unparseable', async () => { + { + mockedReadFile.mockReturnValue('invalid json'); + await expect(getConfiguration('test.file')).rejects.toThrow( + /Configuration file .+ could not be parsed/ + ); + } + { + mockedReadFile.mockReturnValue('{ "foo": 1 "unexpectedString": 2 }'); + await expect(getConfiguration('test.file')).rejects.toThrow( + /Configuration file .+ could not be parsed/ + ); + } + { + mockedReadFile.mockReturnValue('{ "unexpectedEnd": '); + await expect(getConfiguration('test.file')).rejects.toThrow( + /Configuration file .+ could not be parsed/ + ); + } + }); }); diff --git a/node-src/lib/getConfiguration.ts b/node-src/lib/getConfiguration.ts index 28324d3ea..91a2ad56f 100644 --- a/node-src/lib/getConfiguration.ts +++ b/node-src/lib/getConfiguration.ts @@ -1,4 +1,5 @@ -import { readFileSync } from 'fs'; +import { existsSync, readFileSync } from 'fs'; +import JSON5 from 'json5'; import { z, ZodError } from 'zod'; import { invalidConfigurationFile } from '../ui/messages/errors/invalidConfigurationFile'; @@ -48,8 +49,20 @@ const configurationSchema = z export type Configuration = z.infer; +function resolveConfigFileName(configFile?: string): string { + const usedConfigFile = [ + configFile, + 'chromatic.config.json', + 'chromatic.config.jsonc', + 'chromatic.config.json5', + ].find((f?: string) => f && existsSync(f)); + + return usedConfigFile || 'chromatic.config.json'; +} /** - * Parse configuration details from a local config file (typically chromatic.config.json). + * Parse configuration details from a local config file (typically chromatic.config.json, but can + * also use the JSON5 .jsonc and .json5 extensions. If more than one file is present, then the .json + * one will take precedence. * * @param configFile The path to a custom config file (outside of the normal chromatic.config.json * file) @@ -59,10 +72,10 @@ export type Configuration = z.infer; export async function getConfiguration( configFile?: string ): Promise { - const usedConfigFile = configFile || 'chromatic.config.json'; + const usedConfigFile = resolveConfigFileName(configFile); try { const rawJson = readFileSync(usedConfigFile, 'utf8'); - const configuration = configurationSchema.parse(JSON.parse(rawJson)); + const configuration = configurationSchema.parse(JSON5.parse(rawJson)); return { configFile: usedConfigFile, ...configuration }; } catch (err) { // Config file does not exist diff --git a/node-src/ui/messages/errors/unparseableConfigurationFile.stories.ts b/node-src/ui/messages/errors/unparseableConfigurationFile.stories.ts index 84b37af0c..552ce0c76 100644 --- a/node-src/ui/messages/errors/unparseableConfigurationFile.stories.ts +++ b/node-src/ui/messages/errors/unparseableConfigurationFile.stories.ts @@ -11,5 +11,11 @@ try { err = error; } -export const UnparseableConfigurationFile = () => +export const UnparseableConfigurationFileJson = () => unparseableConfigurationFile('./my.config.json', err); + +export const UnparseableConfigurationFileJson5 = () => + unparseableConfigurationFile('./my.config.json5', err); + +export const UnparseableConfigurationFileJsonc = () => + unparseableConfigurationFile('./my.config.jsonc', err); diff --git a/node-src/ui/messages/errors/unparseableConfigurationFile.ts b/node-src/ui/messages/errors/unparseableConfigurationFile.ts index 7ead604b0..50b0e552b 100644 --- a/node-src/ui/messages/errors/unparseableConfigurationFile.ts +++ b/node-src/ui/messages/errors/unparseableConfigurationFile.ts @@ -3,9 +3,12 @@ import { dedent } from 'ts-dedent'; import { error } from '../../components/icons'; -export const unparseableConfigurationFile = (configFile: string, err: Error) => - dedent(chalk` - ${error} Configuration file {bold ${configFile}} could not be parsed, is it valid JSON? +export const unparseableConfigurationFile = (configFile: string, err: Error) => { + const language = + configFile.endsWith('.jsonc') || configFile.endsWith('.json5') ? 'JSON5' : 'JSON'; + return dedent(chalk` + ${error} Configuration file {bold ${configFile}} could not be parsed, is it valid ${language}? The error was: {bold ${err.message}} - `); + `); +}; diff --git a/package.json b/package.json index 0f2b4200a..8eef510a3 100644 --- a/package.json +++ b/package.json @@ -163,6 +163,7 @@ "globals": "^15.3.0", "https-proxy-agent": "^7.0.2", "husky": "^7.0.0", + "json5": "^2.2.3", "jsonfile": "^6.0.1", "junit-report-builder": "2.1.0", "listr": "0.14.3", diff --git a/yarn.lock b/yarn.lock index 762ca554a..837ad023f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7587,6 +7587,7 @@ __metadata: globals: "npm:^15.3.0" https-proxy-agent: "npm:^7.0.2" husky: "npm:^7.0.0" + json5: "npm:^2.2.3" jsonfile: "npm:^6.0.1" junit-report-builder: "npm:2.1.0" listr: "npm:0.14.3"