Skip to content
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
27 changes: 27 additions & 0 deletions src/util/cache/getJsonFromCachedFile.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,13 @@ const loadJsonMock = (loadJsonFile as jest.Mock).mockResolvedValue({
});

describe('getJsonFromCachedFile', () => {
beforeEach(() => {
jest.clearAllMocks();
loadJsonMock.mockResolvedValue({
the: 'json',
});
});

it('can load and parse the JSON from a file stored in cache', async () => {
expect.assertions(2);
await expect(
Expand Down Expand Up @@ -46,4 +53,24 @@ describe('getJsonFromCachedFile', () => {
),
).resolves.toBe(undefined);
});

it('throws malformed cached JSON errors with the cached file path', async () => {
expect.assertions(1);
loadJsonMock.mockRejectedValueOnce(
new Error(
'Invalid JSON in "/home/uname/.emulsify/cache/systems/12345/compound/system.emulsify.json": Expected property name or \'}\' in JSON',
),
);

await expect(
getJsonFromCachedFile(
'systems',
['compound'],
'branch-name',
'system.emulsify.json',
),
).rejects.toThrow(
'Invalid JSON in "/home/uname/.emulsify/cache/systems/12345/compound/system.emulsify.json"',
);
});
});
28 changes: 28 additions & 0 deletions src/util/fs/loadJsonFile.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@ import loadJsonFile from './loadJsonFile.js';
const readFileMock = fs.readFile as jest.Mock;

describe('loadJsonFile', () => {
beforeEach(() => {
jest.clearAllMocks();
});

it('can read and parse json from a file located within the users current path', async () => {
expect.assertions(2);
readFileMock.mockResolvedValueOnce('{ "field": "value" }');
Expand All @@ -17,6 +21,30 @@ describe('loadJsonFile', () => {

it('returns void if the file is not found', async () => {
expect.assertions(1);
readFileMock.mockRejectedValueOnce(
Object.assign(new Error('not found'), { code: 'ENOENT' }),
);

await expect(loadJsonFile('path.json')).resolves.toBe(undefined);
});

it('throws a clear error if an existing file contains malformed json', async () => {
expect.assertions(1);
readFileMock.mockResolvedValueOnce('{ "field": ');

await expect(loadJsonFile('path.json')).rejects.toThrow(
'Invalid JSON in "path.json":',
);
});

it('throws non-missing read errors', async () => {
expect.assertions(1);
readFileMock.mockRejectedValueOnce(
Object.assign(new Error('permission denied'), { code: 'EACCES' }),
);

await expect(loadJsonFile('path.json')).rejects.toThrow(
'permission denied',
);
});
});
27 changes: 20 additions & 7 deletions src/util/fs/loadJsonFile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,25 @@ export default async function loadJsonFile<Output>(
path: string,
): Promise<Output | void> {
try {
return JSON.parse(
await fs.readFile(path, {
encoding: 'utf-8',
}),
) as Output;
} catch {
return undefined;
const json = await fs.readFile(path, {
encoding: 'utf-8',
});

return JSON.parse(json) as Output;
} catch (error) {
if (error instanceof SyntaxError) {
throw new Error(`Invalid JSON in "${path}": ${error.message}`);
}

if (
error &&
typeof error === 'object' &&
'code' in error &&
error.code === 'ENOENT'
) {
return undefined;
}

throw error;
}
}
57 changes: 47 additions & 10 deletions src/util/project/getEmulsifyConfig.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,15 +8,27 @@ import getEmulsifyConfig from './getEmulsifyConfig.js';
const findFileMock = (findFileInCurrentPath as jest.Mock).mockReturnValue(
'/projects/project.emulsify.json',
);
(loadJsonFile as jest.Mock).mockResolvedValue({
emulsify: 'config',
});
const loadJsonFileMock = loadJsonFile as jest.Mock;
const projectConfig = {
project: {
platform: 'drupal',
name: 'Cornflake',
machineName: 'cornflake',
},
starter: {
repository: 'https://github.com/emulsify-ds/emulsify-starter',
},
};

describe('getEmulsifyConfig', () => {
beforeEach(() => {
jest.clearAllMocks();
findFileMock.mockReturnValue('/projects/project.emulsify.json');
loadJsonFileMock.mockResolvedValue(projectConfig);
});

it('can load the Emulsify configuration for the project within the users cwd', async () => {
await expect(getEmulsifyConfig()).resolves.toEqual({
emulsify: 'config',
});
await expect(getEmulsifyConfig()).resolves.toEqual(projectConfig);
});

it('returns void if no Emulsify config file is found within the users cwd', async () => {
Expand All @@ -32,14 +44,39 @@ describe('getEmulsifyConfig', () => {
});

it('handles errors thrown by loadJsonFile', async () => {
(loadJsonFile as jest.Mock).mockImplementationOnce(() => {
loadJsonFileMock.mockImplementationOnce(() => {
throw new Error('loadJsonFile error');
});
await expect(getEmulsifyConfig()).rejects.toThrow('loadJsonFile error');
});

it('handles invalid JSON structure', async () => {
(loadJsonFile as jest.Mock).mockResolvedValueOnce({});
await expect(getEmulsifyConfig()).resolves.toEqual({});
it('reports schema-invalid config missing required project settings', async () => {
loadJsonFileMock.mockResolvedValueOnce({
starter: {
repository: 'https://github.com/emulsify-ds/emulsify-starter',
},
});

await expect(getEmulsifyConfig()).rejects.toThrow(
'Invalid Emulsify project configuration in "/projects/project.emulsify.json": / must have required property \'project\'',
);
});

it('reports schema-invalid variant platform values', async () => {
loadJsonFileMock.mockResolvedValueOnce({
...projectConfig,
system: {
repository: 'https://github.com/emulsify-ds/compound.git',
checkout: 'main',
},
variant: {
platform: 'wordpress',
structureImplementations: [],
},
});

await expect(getEmulsifyConfig()).rejects.toThrow(
'Invalid Emulsify project configuration in "/projects/project.emulsify.json": /variant/platform must be equal to one of the allowed values',
);
});
});
50 changes: 49 additions & 1 deletion src/util/project/getEmulsifyConfig.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,51 @@
import type { EmulsifyProjectConfiguration } from '@emulsify-cli/config';
import { Ajv, type ErrorObject, type ValidateFunction } from 'ajv';
import { EMULSIFY_PROJECT_CONFIG_FILE } from '../../lib/constants.js';
import findFileInCurrentPath from '../fs/findFileInCurrentPath.js';
import loadJsonFile from '../fs/loadJsonFile.js';

let validateProjectConfig: ValidateFunction | undefined;

async function getProjectConfigValidator(): Promise<ValidateFunction> {
if (!validateProjectConfig) {
const [{ default: projectConfigSchema }, { default: variantSchema }] =
await Promise.all([
import('../../schemas/emulsifyProjectConfig.json', {
with: { type: 'json' },
}),
import('../../schemas/variant.json', { with: { type: 'json' } }),
]);
const ajv = new Ajv({ allErrors: true });
ajv.addSchema(variantSchema, 'variant.json');
validateProjectConfig = ajv.compile(projectConfigSchema);
}

return validateProjectConfig;
}

function formatProjectConfigError(error: ErrorObject): string {
const location = error.instancePath || '/';
return `${location} ${error.message}`;
}

async function validateEmulsifyConfig(
config: unknown,
path: string,
): Promise<EmulsifyProjectConfiguration> {
const validate = await getProjectConfigValidator();
if (!validate(config)) {
const errors = (validate.errors || [])
.map(formatProjectConfigError)
.join('; ');

throw new Error(
`Invalid Emulsify project configuration in "${path}": ${errors}`,
);
}

return config as EmulsifyProjectConfiguration;
}

/**
* Finds the Emulsify project configuration, loads, and returns it.
*
Expand All @@ -15,5 +58,10 @@ export default async function getEmulsifyConfig(): Promise<EmulsifyProjectConfig
return undefined;
}

return loadJsonFile<EmulsifyProjectConfiguration>(path);
const config = await loadJsonFile<unknown>(path);
if (config === undefined) {
return undefined;
}

return validateEmulsifyConfig(config, path);
}
Loading