Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

✨ Add support for JSON5 as a configuration file. #1118

Merged
merged 1 commit into from
Nov 7, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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) => {
jwir3 marked this conversation as resolved.
Show resolved Hide resolved
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
Loading