Skip to content

Commit

Permalink
✨ Add support for JSON5 as a configuration file.
Browse files Browse the repository at this point in the history
This adds support for JSON5 as a configuration file language, allowing
for an extension to JSON that supports comments and other features.

Fixes CAP-2357.
Fixes #1114.
  • Loading branch information
jwir3 committed Nov 5, 2024
1 parent 89e6d25 commit aa5e4fc
Show file tree
Hide file tree
Showing 7 changed files with 258 additions and 59 deletions.
3 changes: 3 additions & 0 deletions node-src/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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))),
};
});
Expand Down
272 changes: 222 additions & 50 deletions node-src/lib/getConfiguration.test.ts
Original file line number Diff line number Diff line change
@@ -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',
Expand Down Expand Up @@ -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({
Expand Down Expand Up @@ -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/
);
}
});
});
21 changes: 17 additions & 4 deletions node-src/lib/getConfiguration.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -48,8 +49,20 @@ const configurationSchema = z

export type Configuration = z.infer<typeof configurationSchema>;

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)
Expand All @@ -59,10 +72,10 @@ export type Configuration = z.infer<typeof configurationSchema>;
export async function getConfiguration(
configFile?: string
): Promise<Configuration & { configFile?: string }> {
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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
11 changes: 7 additions & 4 deletions node-src/ui/messages/errors/unparseableConfigurationFile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}}
`);
`);
};
Loading

0 comments on commit aa5e4fc

Please sign in to comment.