Skip to content
Open
15 changes: 15 additions & 0 deletions src/fn/upload-ui-extensions.js
Comment thread
Vedd-Patel marked this conversation as resolved.
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
const projectPaths = require('../lib/project-paths');
const { uploadUiExtensions } = require('../lib/upload-ui-extensions');

const executeUploadUiExtensions = async (environment) => {
const uiExtensionsDir = `${environment.pathToProject}/${projectPaths.UI_EXTENSIONS_PATH}`;

let specificExtensions = [];
if (environment.extraArgs?.length) {
specificExtensions = environment.extraArgs.filter(arg => !arg.startsWith('--'));
}

await uploadUiExtensions(uiExtensionsDir, specificExtensions);
};

module.exports = executeUploadUiExtensions;
1 change: 1 addition & 0 deletions src/lib/project-paths.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,4 +11,5 @@ module.exports = {
RESOURCE_CONFIG_PATH: 'resources.json',
RESOURCES_DIR_PATH: 'resources',
EXTENSION_LIBS_PATH: 'extension-libs',
UI_EXTENSIONS_PATH: 'ui-extensions',
};
117 changes: 117 additions & 0 deletions src/lib/upload-ui-extensions.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
const fs = require('node:fs');
const path = require('node:path');
const Joi = require('joi');
const environment = require('./environment');
const log = require('./log');
const insertOrReplace = require('./insert-or-replace');
const warnUploadOverwrite = require('./warn-upload-overwrite');
const pouch = require('./db');
const attachmentFromFile = require('./attachment-from-file');

const schema = Joi.object({
type: Joi.string().valid('app_main_tab', 'app_drawer_tab').required(),
title: Joi.string().required(),
icon: Joi.string().required(),
roles: Joi.array().items(Joi.string()),
config: Joi.object().unknown(true)
});

//validates the name against custom web component standards
const validateExtensionName = (name) => {
const startsWithLowercase = /^[a-z]/.test(name);
const hasHyphen = name.includes('-');
const hasValidChars = /^[a-z0-9_.-]+$/.test(name);

return startsWithLowercase && hasHyphen && hasValidChars;
};

const getNamesToUpload = (uiExtensionsDir) => {
const allFiles = fs.readdirSync(uiExtensionsDir);
const extensionNames = new Set(
allFiles
.filter(f => f.endsWith('.js') || f.endsWith('.properties.json'))
.map(f => f.replace(/(\.properties\.json|\.js)$/, ''))
);

return Array.from(extensionNames);
};

const getExtensionDoc = (uiExtensionsDir, name) => {
if (!validateExtensionName(name)) {
throw new Error(
`UI Extension name "${name}" is invalid. It must start with a lowercase letter, ` +
'contain at least one hyphen, and use only lowercase letters, digits, hyphens, ' +
'periods, or underscores.'
);
}

const jsPath = path.join(uiExtensionsDir, `${name}.js`);
const propsPath = path.join(uiExtensionsDir, `${name}.properties.json`);

if (!fs.existsSync(jsPath) || !fs.existsSync(propsPath)) {
throw new Error(`UI Extension "${name}" is missing either its .js or .properties.json file.`);
}

let propsContent;
try {
const rawProps = fs.readFileSync(propsPath, 'utf-8');
propsContent = JSON.parse(rawProps);
} catch (err) {
throw new Error(`Failed to parse ${name}.properties.json - Invalid JSON format: ${err.message}`);
}

const validation = schema.validate(propsContent);
if (validation.error) {
throw new Error(`Validation error for UI extension "${name}": ${validation.error.message}`);
}

return {
_id: `ui-extension:${name}`,
type: 'ui-extension',
...propsContent,
_attachments: {
'extension.js': attachmentFromFile(jsPath)
}
Comment thread
Vedd-Patel marked this conversation as resolved.
};
};

const uploadDocToDb = async (db, doc, name) => {
const changes = await warnUploadOverwrite.preUploadDoc(db, doc);
if (!changes) {
log.info(`UI Extension "${name}" not uploaded as already up to date`);
return;
}

await insertOrReplace(db, doc);
log.info(`UI Extension "${name}" upload complete`);
await warnUploadOverwrite.postUploadDoc(db, doc);
};

const uploadUiExtensions = async (uiExtensionsDir, specificExtensions = []) => {
if (!fs.existsSync(uiExtensionsDir)) {
log.info(`No directory found at "${uiExtensionsDir}" - not uploading ui-extensions`);
return;
}

// if specific extensions are provided, bypass directory reading
// missing files will be caught by getExtensionDoc
const namesToUpload = specificExtensions.length ? specificExtensions : getNamesToUpload(uiExtensionsDir);

if (!namesToUpload.length) {
log.info('No UI extensions to upload.');
return;
}

log.info(`Found UI extensions: ${namesToUpload.join(', ')}`);
Comment thread
Vedd-Patel marked this conversation as resolved.

// process all docs before uploading any to ensure validation passes for everything
const namesWithDocs = namesToUpload.map(name => [name, getExtensionDoc(uiExtensionsDir, name)]);

const db = pouch(environment.apiUrl);

for (const [name, doc] of namesWithDocs) {
await uploadDocToDb(db, doc, name);
}
};

module.exports = { uploadUiExtensions };
82 changes: 82 additions & 0 deletions test/lib/upload-ui-extensions.spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
const chai = require('chai');
const sinon = require('sinon');
const fs = require('node:fs');

const { uploadUiExtensions } = require('../../src/lib/upload-ui-extensions');
const log = require('../../src/lib/log');

const expect = chai.expect;
chai.use(require('chai-as-promised'));

describe('Upload UI Extensions Library', () => {
let sandbox;

beforeEach(() => {
sandbox = sinon.createSandbox();
sandbox.stub(log, 'info');
});

afterEach(() => {
sandbox.restore();
});

it('should log and return if the ui-extensions directory does not exist', async () => {
sandbox.stub(fs, 'existsSync').returns(false);

await uploadUiExtensions('/fake/path');

expect(log.info.callCount).to.equal(1);
expect(log.info.args[0][0]).to.include('No directory found at "/fake/path"');
});

it('should log and return if no valid extension files are found', async () => {
sandbox.stub(fs, 'existsSync').returns(true);
sandbox.stub(fs, 'readdirSync').returns(['random-file.txt', 'image.png']);

await uploadUiExtensions('/fake/path');

expect(log.info.callCount).to.equal(1);
expect(log.info.args[0][0]).to.equal('No UI extensions to upload.');
});

it('should throw an error if an extension name does not match custom element standards', async () => {
sandbox.stub(fs, 'existsSync').returns(true);
// "myExtension" is invalid (has uppercase, no hyphen)
sandbox.stub(fs, 'readdirSync').returns(['myExtension.js', 'myExtension.properties.json']);

await expect(uploadUiExtensions('/fake/path'))
.to.be.rejectedWith('UI Extension name "myExtension" is invalid.');
});

it('should throw an error if missing either the .js or .properties.json file', async () => {
sandbox.stub(fs, 'readdirSync').returns(['valid-name.js']);

sandbox.stub(fs, 'existsSync').callsFake((path) => !path.includes('.properties.json'));

await expect(uploadUiExtensions('/fake/path'))
.to.be.rejectedWith('UI Extension "valid-name" is missing either its .js or .properties.json file.');
});

it('should throw an error if properties.json is invalid JSON', async () => {
sandbox.stub(fs, 'existsSync').returns(true);
sandbox.stub(fs, 'readdirSync').returns(['valid-name.js', 'valid-name.properties.json']);

// simulates a broken JSON file
sandbox.stub(fs, 'readFileSync').returns('invalid json { oops');

await expect(uploadUiExtensions('/fake/path'))
.to.be.rejectedWith('Failed to parse valid-name.properties.json - Invalid JSON format:');
});

it('should throw an error if properties.json fails Joi schema validation', async () => {
sandbox.stub(fs, 'existsSync').returns(true);
sandbox.stub(fs, 'readdirSync').returns(['valid-name.js', 'valid-name.properties.json']);

// missing required fields like 'icon' and 'config'
const invalidProps = { type: 'app_main_tab', title: 'My Extension' };
sandbox.stub(fs, 'readFileSync').returns(JSON.stringify(invalidProps));

await expect(uploadUiExtensions('/fake/path'))
.to.be.rejectedWith('Validation error for UI extension "valid-name":');
});
});
Loading