Skip to content

Commit

Permalink
Merge pull request #814 from chromaui/tom/ap-3623-add-cli-support-for…
Browse files Browse the repository at this point in the history
…-a-chromaticjs-config-file-that-allows

Add support for a JSON configuration file
  • Loading branch information
tmeasday committed Sep 14, 2023
2 parents cc4c525 + a8acc0e commit a0b14d8
Show file tree
Hide file tree
Showing 20 changed files with 371 additions and 44 deletions.
1 change: 1 addition & 0 deletions .circleci/config.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ jobs:
build:
docker:
- image: cimg/node:16.18.0
resource_class: xlarge

working_directory: ~/repo

Expand Down
14 changes: 8 additions & 6 deletions node-src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,6 @@ import invalidPackageJson from './ui/messages/errors/invalidPackageJson';
import noPackageJson from './ui/messages/errors/noPackageJson';
import { getBranch, getCommit, getSlug, getUserEmail, getUncommittedHash } from './git/git';
import { emailHash } from './lib/emailHash';

/**
Make keys of `T` outside of `R` optional.
*/
Expand All @@ -38,7 +37,7 @@ interface Output {
inheritedCaptureCount: number;
}

export type { Flags, Options, TaskName, Context } from './types';
export type { Flags, Options, TaskName, Context, Configuration } from './types';

export async function run({
argv = [],
Expand Down Expand Up @@ -81,8 +80,9 @@ export async function run({
setExitCode(ctx, exitCodes.OK);

ctx.http = (ctx.http as HTTPClient) || new HTTPClient(ctx);
ctx.extraOptions = options;

await runAll(ctx, options);
await runAll(ctx);

return {
// Keep this in sync with the configured outputs in action.yml
Expand All @@ -102,15 +102,15 @@ export async function run({
};
}

export async function runAll(ctx, options?: Partial<Options>) {
export async function runAll(ctx) {
// Run these in parallel; neither should ever reject
await Promise.all([runBuild(ctx, options), checkForUpdates(ctx)]);
await Promise.all([runBuild(ctx), checkForUpdates(ctx)]);

if (ctx.exitCode === 0 || ctx.exitCode === 1) {
await checkPackageJson(ctx);
}

if (ctx.flags.diagnostics) {
if (ctx.options.diagnostics) {
await writeChromaticDiagnostics(ctx);
}
}
Expand Down Expand Up @@ -147,3 +147,5 @@ export async function getGitInfo(): Promise<GitInfo> {
userEmailHash,
};
}

export { getConfiguration } from './lib/getConfiguration';
2 changes: 2 additions & 0 deletions node-src/lib/compareBaseline.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import { compareBaseline } from './compareBaseline';
import { getDependencies } from './getDependencies';
import TestLogger from './testLogger';

jest.setTimeout(30 * 1000);

const getContext: any = (baselineCommits: string[]) => ({
log: new TestLogger(),
git: { baselineCommits },
Expand Down
53 changes: 53 additions & 0 deletions node-src/lib/getConfiguration.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { readFile } from 'jsonfile';
import { getConfiguration } from './getConfiguration';

jest.mock('jsonfile');
const mockedReadFile = jest.mocked(readFile);

beforeEach(() => {
mockedReadFile.mockReset();
});

it('reads configuration successfully', async () => {
mockedReadFile.mockResolvedValue({ projectToken: 'json-file-token' });

expect(await getConfiguration()).toEqual({ projectToken: 'json-file-token' });
});

it('reads from chromatic.config.json by default', async () => {
mockedReadFile.mockResolvedValue({ projectToken: 'json-file-token' }).mockClear();
await getConfiguration();

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

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

expect(mockedReadFile).toHaveBeenCalledWith('test.file');
});

it('returns nothing if there is no config file and it was not specified', async () => {
mockedReadFile.mockRejectedValue(new Error('ENOENT'));

expect(await getConfiguration()).toEqual({});
});

it('returns nothing if there is no config file and it was specified', async () => {
mockedReadFile.mockRejectedValue(new Error('ENOENT'));

await expect(getConfiguration('test.file')).rejects.toThrow(/could not be found/);
});

it('errors if config file contains invalid data', async () => {
mockedReadFile.mockResolvedValue({ projectToken: 1 });

await expect(getConfiguration('test.file')).rejects.toThrow(/projectToken/);
});

it('errors if config file contains unknown keys', async () => {
mockedReadFile.mockResolvedValue({ random: 1 });

await expect(getConfiguration('test.file')).rejects.toThrow(/random/);
});
63 changes: 63 additions & 0 deletions node-src/lib/getConfiguration.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import { readFile } from 'jsonfile';
import { ZodError, z } from 'zod';
import { missingConfigurationFile } from '../ui/messages/errors/missingConfigurationFile';
import { unparseableConfigurationFile } from '../ui/messages/errors/unparseableConfigurationFile';
import { invalidConfigurationFile } from '../ui/messages/errors/invalidConfigurationFile';

const configurationSchema = z
.object({
projectId: z.string(),
projectToken: z.string(), // deprecated

onlyChanged: z.union([z.string(), z.boolean()]),
onlyStoryFiles: z.array(z.string()),
onlyStoryNames: z.array(z.string()),
untraced: z.array(z.string()),
externals: z.array(z.string()),
debug: z.boolean(),
diagnostics: z.union([z.string(), z.boolean()]),
junitReport: z.union([z.string(), z.boolean()]),
zip: z.boolean(),
autoAcceptChanges: z.union([z.string(), z.boolean()]),
exitZeroOnChanges: z.union([z.string(), z.boolean()]),
exitOnceUploaded: z.union([z.string(), z.boolean()]),
ignoreLastBuildOnBranch: z.string(),

buildScriptName: z.string(),
outputDir: z.string(),

storybookBuildDir: z.string(),
storybookBaseDir: z.string(),
storybookConfigDir: z.string(),
})
.partial()
.strict();

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

export async function getConfiguration(configFile?: string) {
const usedConfigFile = configFile || 'chromatic.config.json';
try {
const rawJson = await readFile(usedConfigFile);

return configurationSchema.parse(rawJson);
} catch (err) {
// Config file does not exist
if (err.message.match(/ENOENT/)) {
// The user passed no configFile option so it's OK for the file not to exist
if (!configFile) {
return {};
}
if (configFile) {
throw new Error(missingConfigurationFile(configFile));
}
}
if (err.message.match('Unexpected string')) {
throw new Error(unparseableConfigurationFile(usedConfigFile, err));
}
if (err instanceof ZodError) {
throw new Error(invalidConfigurationFile(usedConfigFile, err));
}
throw err;
}
}
2 changes: 2 additions & 0 deletions node-src/lib/getDependencies.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import packageJson from '../../package.json';
import { checkoutFile } from '../git/git';
import TestLogger from './testLogger';

jest.setTimeout(30 * 1000);

const ctx = { log: new TestLogger() } as any;

describe('getDependencies', () => {
Expand Down
48 changes: 43 additions & 5 deletions node-src/lib/getOptions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,11 +34,11 @@ describe('getOptions', () => {
projectToken: 'cli-code',
buildScriptName: 'build-storybook',
fromCI: !!process.env.CI,
autoAcceptChanges: undefined,
exitZeroOnChanges: undefined,
exitOnceUploaded: undefined,
autoAcceptChanges: false,
exitZeroOnChanges: false,
exitOnceUploaded: false,
interactive: false,
verbose: false,
debug: false,
originalArgv: ['--project-token', 'cli-code'],
});
});
Expand Down Expand Up @@ -68,7 +68,7 @@ describe('getOptions', () => {
autoAcceptChanges: true,
exitZeroOnChanges: true,
exitOnceUploaded: true,
verbose: true,
debug: true,
interactive: false,
});
});
Expand Down Expand Up @@ -123,6 +123,44 @@ describe('getOptions', () => {
const flags = ['--only-changed', '--externals', 'foo', '--externals', '', '--externals', 'bar'];
expect(getOptions(getContext(flags))).toMatchObject({ externals: ['foo', 'bar'] });
});

it('allows you to set options with configuration', async () => {
expect(
getOptions({ ...getContext([]), configuration: { projectToken: 'config-token' } })
).toMatchObject({
projectToken: 'config-token',
});
});

it('allows you to override configuration with flags', async () => {
expect(
getOptions({
...getContext(['--project-token', 'cli-token']),
configuration: { projectToken: 'config-token' },
})
).toMatchObject({
projectToken: 'cli-token',
});
});

it('allows you to set options with extraOptions', async () => {
expect(
getOptions({ ...getContext([]), extraOptions: { projectToken: 'extra-token' } })
).toMatchObject({
projectToken: 'extra-token',
});
});

it('allows you to override flags with extraOptions', async () => {
expect(
getOptions({
...getContext(['--project-token', 'cli-token']),
extraOptions: { projectToken: 'extra-token' },
})
).toMatchObject({
projectToken: 'extra-token',
});
});
});

describe('getStorybookConfiguration', () => {
Expand Down

0 comments on commit a0b14d8

Please sign in to comment.