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 c4086bf
Show file tree
Hide file tree
Showing 6 changed files with 176 additions and 58 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
197 changes: 147 additions & 50 deletions node-src/lib/getConfiguration.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { readFileSync } from 'fs';
import { beforeEach, expect, it, vi } from 'vitest';
import { existsSync, PathLike, readFileSync } from 'fs';
import { beforeEach, describe, expect, it, vi } from 'vitest';

import { getConfiguration } from './getConfiguration';

Expand All @@ -10,7 +10,7 @@ beforeEach(() => {
mockedReadFile.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 +83,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 +195,83 @@ 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', () => {
it('reads from chromatic.config.json by default if no other files exist', async () => {
mockedReadFile.mockReturnValue(JSON.stringify({ projectToken: 'json-file-token' })).mockClear();
await getConfiguration();

expect(mockedReadFile).toHaveBeenCalledWith('chromatic.config.json', 'utf8');
});
expect(mockedReadFile).toHaveBeenCalledWith('chromatic.config.json', 'utf8');
});

it('can read from a different location', async () => {
mockedReadFile.mockReturnValue(JSON.stringify({ projectToken: 'json-file-token' })).mockClear();
await getConfiguration('test.file');
it('reads chromatic.config.jsonc if it exists', async () => {
mockedReadFile.mockReturnValue(JSON.stringify({ projectToken: 'json-file-token' })).mockClear();
const mockedExistsSync = vi.mocked(existsSync).mockImplementation((path: PathLike) => {
if (path === 'chromatic.config.jsonc') {
return true;
}
return false;
});

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.jsonc', 'utf8');

mockedExistsSync.mockClear();
});

expect(await getConfiguration()).toEqual({});
});
it('can read from a different location', async () => {
mockedReadFile.mockReturnValue(JSON.stringify({ projectToken: 'json-file-token' })).mockClear();
await getConfiguration('test.file');

it('returns nothing if there is no config file and it was specified', async () => {
mockedReadFile.mockImplementation(() => {
throw new Error('ENOENT');
expect(mockedReadFile).toHaveBeenCalledWith('test.file', 'utf8');
});

await expect(getConfiguration('test.file')).rejects.toThrow(/could not be found/);
});
it('returns nothing if there is no config file and it was not specified', async () => {
mockedReadFile.mockImplementation(() => {
throw new Error('ENOENT');
});

it('errors if config file contains invalid data', async () => {
mockedReadFile.mockReturnValue(JSON.stringify({ projectToken: 1 }));
expect(await getConfiguration()).toEqual({});
});

await expect(getConfiguration('test.file')).rejects.toThrow(/projectToken/);
});
it('returns nothing if there is no config file and it was specified', async () => {
mockedReadFile.mockImplementation(() => {
throw new Error('ENOENT');
});

it('errors if config file contains unknown keys', async () => {
mockedReadFile.mockReturnValue(JSON.stringify({ random: 1 }));
await expect(getConfiguration('test.file')).rejects.toThrow(/could not be found/);
});

await expect(getConfiguration('test.file')).rejects.toThrow(/random/);
});
it('errors if config file contains invalid data', async () => {
mockedReadFile.mockReturnValue(JSON.stringify({ projectToken: 1 }));

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/
);
}
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/
);
}
});
});
20 changes: 16 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,19 @@ const configurationSchema = z

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

function resolveConfigFileName(configFile?: string): string {
let usedConfigFile = configFile || 'chromatic.config.json';
if (!configFile && !existsSync(usedConfigFile) && existsSync('chromatic.config.jsonc')) {
usedConfigFile = 'chromatic.config.jsonc';
}

return usedConfigFile;
}

/**
* 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 extension. If both files are present, then the .json will take
* precedence.
*
* @param configFile The path to a custom config file (outside of the normal chromatic.config.json
* file)
Expand All @@ -59,10 +71,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
10 changes: 6 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,11 @@ 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') ? 'JSON5' : 'JSON';
return dedent(chalk`
${error} Configuration file {bold ${configFile}} could not be parsed, is it valid ${language}?
The error was: {bold ${err.message}}
`);
`);
};
3 changes: 3 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -257,5 +257,8 @@
"storybook": {
"icon": "https://user-images.githubusercontent.com/263385/101995175-2e087800-3c96-11eb-9a33-9860a1c3ce62.gif",
"displayName": "Chromatic"
},
"dependencies": {
"json5": "^2.2.3"
}
}
1 change: 1 addition & 0 deletions yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down

0 comments on commit c4086bf

Please sign in to comment.