diff --git a/src/util/cache/getJsonFromCachedFile.test.ts b/src/util/cache/getJsonFromCachedFile.test.ts index d1a9002..d878c4c 100644 --- a/src/util/cache/getJsonFromCachedFile.test.ts +++ b/src/util/cache/getJsonFromCachedFile.test.ts @@ -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( @@ -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"', + ); + }); }); diff --git a/src/util/fs/loadJsonFile.test.ts b/src/util/fs/loadJsonFile.test.ts index 0a5c60c..a0fd622 100644 --- a/src/util/fs/loadJsonFile.test.ts +++ b/src/util/fs/loadJsonFile.test.ts @@ -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" }'); @@ -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', + ); + }); }); diff --git a/src/util/fs/loadJsonFile.ts b/src/util/fs/loadJsonFile.ts index 04ff345..8d548b5 100644 --- a/src/util/fs/loadJsonFile.ts +++ b/src/util/fs/loadJsonFile.ts @@ -11,12 +11,25 @@ export default async function loadJsonFile( path: string, ): Promise { 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; } } diff --git a/src/util/project/getEmulsifyConfig.test.ts b/src/util/project/getEmulsifyConfig.test.ts index b746a13..1960611 100644 --- a/src/util/project/getEmulsifyConfig.test.ts +++ b/src/util/project/getEmulsifyConfig.test.ts @@ -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 () => { @@ -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', + ); }); }); diff --git a/src/util/project/getEmulsifyConfig.ts b/src/util/project/getEmulsifyConfig.ts index 70acfdb..a5b6def 100644 --- a/src/util/project/getEmulsifyConfig.ts +++ b/src/util/project/getEmulsifyConfig.ts @@ -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 { + 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 { + 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. * @@ -15,5 +58,10 @@ export default async function getEmulsifyConfig(): Promise(path); + const config = await loadJsonFile(path); + if (config === undefined) { + return undefined; + } + + return validateEmulsifyConfig(config, path); }