diff --git a/examples/empty.config.js b/examples/empty.config.js new file mode 100644 index 0000000..0c0c42d --- /dev/null +++ b/examples/empty.config.js @@ -0,0 +1 @@ +module.exports = () => {} diff --git a/examples/notvalid.config.js b/examples/notvalid.config.js new file mode 100644 index 0000000..d2aa9fc --- /dev/null +++ b/examples/notvalid.config.js @@ -0,0 +1,12 @@ +module.exports = () => { + return [ + { + titles: 'parent 1', + tasks: [ + { + titles: 'child 1', + }, + ], + }, + ] +} diff --git a/package-lock.json b/package-lock.json index 0f02bfe..faadc05 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,6 +20,7 @@ "enquirer": "^2.4.1", "esbuild": "^0.19.4", "execa": "^8.0.1", + "joi": "^17.11.0", "listr2": "^7.0.1", "np": "^8.0.4", "tsx": "^3.13.0", @@ -590,6 +591,21 @@ "node": ">=12" } }, + "node_modules/@hapi/hoek": { + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/@hapi/hoek/-/hoek-9.3.0.tgz", + "integrity": "sha512-/c6rf4UJlmHlC9b5BaNvzAcFv7HZ2QHaV0D4/HNlBdvFnvQq8RI4kYdhyPCl7Xj+oWvTWQ8ujhqS53LIgAe6KQ==", + "dev": true + }, + "node_modules/@hapi/topo": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@hapi/topo/-/topo-5.1.0.tgz", + "integrity": "sha512-foQZKJig7Ob0BMAYBfcJk8d77QtOe7Wo4ox7ff1lQYoNNAb6jwcY1ncdoy2e9wQZzvNy7ODZCYJkK8kzmcAnAg==", + "dev": true, + "dependencies": { + "@hapi/hoek": "^9.0.0" + } + }, "node_modules/@istanbuljs/schema": { "version": "0.1.3", "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.3.tgz", @@ -779,6 +795,27 @@ } } }, + "node_modules/@sideway/address": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@sideway/address/-/address-4.1.4.tgz", + "integrity": "sha512-7vwq+rOHVWjyXxVlR76Agnvhy8I9rpzjosTESvmhNeXOXdZZB15Fl+TI9x1SiHZH5Jv2wTGduSxFDIaq0m3DUw==", + "dev": true, + "dependencies": { + "@hapi/hoek": "^9.0.0" + } + }, + "node_modules/@sideway/formula": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@sideway/formula/-/formula-3.0.1.tgz", + "integrity": "sha512-/poHZJJVjx3L+zVD6g9KgHfYnb443oi7wLu/XKojDviHy6HOEOA6z1Trk5aR1dGcmPenJEgb2sK2I80LeS3MIg==", + "dev": true + }, + "node_modules/@sideway/pinpoint": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@sideway/pinpoint/-/pinpoint-2.0.0.tgz", + "integrity": "sha512-RNiOoTPkptFtSVzQevY/yWtZwf/RxyVnPy/OcA9HBM3MlGDnBEYL5B41H0MTn0Uec8Hi+2qUtTfG2WWZBmMejQ==", + "dev": true + }, "node_modules/@sinclair/typebox": { "version": "0.27.8", "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.8.tgz", @@ -3661,6 +3698,19 @@ "node": ">=8" } }, + "node_modules/joi": { + "version": "17.11.0", + "resolved": "https://registry.npmjs.org/joi/-/joi-17.11.0.tgz", + "integrity": "sha512-NgB+lZLNoqISVy1rZocE9PZI36bL/77ie924Ri43yEvi9GUUMPeyVIr8KdFTMUlby1p0PBYMk9spIxEUQYqrJQ==", + "dev": true, + "dependencies": { + "@hapi/hoek": "^9.0.0", + "@hapi/topo": "^5.0.0", + "@sideway/address": "^4.1.3", + "@sideway/formula": "^3.0.1", + "@sideway/pinpoint": "^2.0.0" + } + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", diff --git a/package.json b/package.json index d310dc7..9e66900 100644 --- a/package.json +++ b/package.json @@ -40,6 +40,7 @@ "enquirer": "^2.4.1", "esbuild": "^0.19.4", "execa": "^8.0.1", + "joi": "^17.11.0", "listr2": "^7.0.1", "np": "^8.0.4", "tsx": "^3.13.0", diff --git a/src/builder.test.js b/src/builder.test.js new file mode 100644 index 0000000..6876686 --- /dev/null +++ b/src/builder.test.js @@ -0,0 +1,88 @@ +import { describe, it, expect, beforeEach } from 'vitest' +import { Builder } from './builder' +import { arrayOfTasksSchema, taskSchema } from './validator' + +describe('Builder', () => { + let builder + + beforeEach(() => { + builder = new Builder() + }) + + describe('task', () => { + it('adds a task definition to the list of tasks', () => { + builder.task('Task 1', () => {}) + builder.task('Task 2', () => {}) + + const { tasks } = builder.build() + + expect(tasks).toHaveLength(2) + expect(tasks[0].title).toBe('Task 1') + expect(tasks[1].title).toBe('Task 2') + }) + + it('should be validated using the schema using the builder', () => { + builder.task('Task 1', () => {}) + builder.task('Task 2', () => {}) + + const { tasks } = builder.build() + const { error } = arrayOfTasksSchema.validate(tasks) + expect(error).toBe(undefined) + }) + }) + + describe('subTask', () => { + it('sets the current task to the newly added task definition', () => { + builder.task('Task 1', () => {}) + builder.subTask('Subtask 1', () => {}) + builder.task('Task 2', () => {}) + + const { tasks } = builder.build() + + expect(tasks[0].title).toBe('Task 1') + expect(tasks[0].tasks).toHaveLength(1) + expect(tasks[0].tasks[0].title).toBe('Subtask 1') + + expect(tasks[1].title).toBe('Task 2') + expect(tasks[1].tasks).toHaveLength(0) + }) + + it('adds a subtask definition to the current task', () => { + builder.task('Task 1', () => {}) + builder.subTask('Subtask 1', () => {}) + builder.subTask('Subtask 2', () => {}) + + const { tasks } = builder.build() + + expect(tasks[0].tasks).toHaveLength(2) + expect(tasks[0].tasks[0].title).toBe('Subtask 1') + expect(tasks[0].tasks[1].title).toBe('Subtask 2') + }) + + it('does not add a subtask definition if there is no current task', () => { + builder.subTask('Subtask 1', () => {}) + + const { tasks } = builder.build() + + expect(tasks).toHaveLength(0) + }) + }) + + describe('setConcurrent', () => { + it('sets the concurrent option to true', () => { + builder.setConcurrent() + + const { options } = builder.build() + + expect(options.concurrent).toBe(true) + }) + + it('sets the concurrent option to false', () => { + builder.setConcurrent(false) + + const { options } = builder.build() + + expect(options.concurrent).toBe(false) + }) + }) +}) diff --git a/src/reader.js b/src/reader.js index 8a7ced4..4530c7f 100644 --- a/src/reader.js +++ b/src/reader.js @@ -2,23 +2,27 @@ import { ListrEnquirerPromptAdapter } from '@listr2/prompt-adapter-enquirer' import { Listr } from 'listr2' import { $ } from 'execa' import { Builder } from './builder' +import { arrayOfTasksSchema } from './validator' function buildContext(ctx, task) { - ctx.task = task - ctx.bash = $ - ctx.prompt = task.prompt(ListrEnquirerPromptAdapter).run.bind(task) - ctx.delay = (ms) => { - return new Promise((resolve) => { - setTimeout(resolve, ms) - }) - } - ctx.checkEnv = (envName) => { - if (!process.env[envName]) { - throw new Error(`env ${envName} is not set`) - } + const newCtx = { + ...ctx, + task, + bash: $, + prompt: task.prompt(ListrEnquirerPromptAdapter).run.bind(task), + delay: (ms) => { + return new Promise((resolve) => { + setTimeout(resolve, ms) + }) + }, + checkEnv: (envName) => { + if (!process.env[envName]) { + throw new Error(`env ${envName} is not set`) + } + }, } - return ctx + return newCtx } function mapper(def) { @@ -48,12 +52,19 @@ export async function startFile(filePath) { const options = Array.isArray(data) ? {} : data?.options const definitions = Array.isArray(data) ? data : data.tasks - const tasksFromDefinitions = definitions.map(mapper) + const { error } = arrayOfTasksSchema.validate(definitions, { + abortEarly: false, + }) + if (error) throw error + + const tasksFromDefinitions = definitions.map(mapper) const tasks = new Listr(tasksFromDefinitions, { concurrent: options?.concurrent ?? false, exitOnError: false, }) await tasks.run() + + return true } diff --git a/src/reader.test.js b/src/reader.test.js new file mode 100644 index 0000000..f21debc --- /dev/null +++ b/src/reader.test.js @@ -0,0 +1,31 @@ +import { it, describe, expect, vi } from 'vitest' +import { startFile } from './reader' +import path from 'node:path' + +vi.mock('listr2') + +describe('startFile', () => { + it('should throw an error if no data is returned from the config file', async () => { + const filePath = path.resolve(__dirname, '../examples/empty.config.js') + await expect(startFile(filePath)).rejects.toThrow( + 'No data returned from config file' + ) + }) + + it('should not throw an error for a raw mode', async () => { + const filePath = path.resolve(__dirname, '../examples/raw.config.js') + await expect(startFile(filePath)).resolves.toBeTruthy() + }) + + it('should not throw an error for a builder mode', async () => { + const filePath = path.resolve(__dirname, '../examples/builder.config.js') + await expect(startFile(filePath)).resolves.toBeTruthy() + }) + + it('should throw an error for an invalid schema', async () => { + const filePath = path.resolve(__dirname, '../examples/notvalid.config.js') + await expect(startFile(filePath)).rejects.toThrowError( + '"[0].title" is required. "[0].tasks[0].title" is required. "[0].tasks[0].titles" is not allowed. "[0].titles" is not allowed' + ) + }) +}) diff --git a/src/validator.js b/src/validator.js new file mode 100644 index 0000000..6bf1590 --- /dev/null +++ b/src/validator.js @@ -0,0 +1,11 @@ +import Joi from 'joi' + +export const taskSchema = Joi.object({ + title: Joi.string().required(), + task: Joi.function(), + tasks: Joi.array().items(Joi.link('#task')), +}).id('task') + +export const arrayOfTasksSchema = Joi.array() + .items(taskSchema) + .id('arrayOfTasks') diff --git a/src/validator.test.js b/src/validator.test.js new file mode 100644 index 0000000..67cbacf --- /dev/null +++ b/src/validator.test.js @@ -0,0 +1,48 @@ +import { describe, it, expect } from 'vitest' +import { taskSchema } from './validator.js' + +describe('taskSchema', () => { + it('should validate a valid task object', () => { + const validTask = { + title: 'My Task', + task: () => {}, + tasks: [ + { + title: 'Subtask 1', + task: () => {}, + tasks: [ + { + title: 'Subtask 1.1', + task: () => {}, + }, + ], + }, + ], + } + + const { error } = taskSchema.validate(validTask) + expect(error).toBe(undefined) + }) + + it('should not validate an invalid task object', () => { + const invalidTask = { + title: 'My Task', + task: () => {}, + tasks: [ + { + title: 'Subtask 1', + task: () => {}, + tasks: [ + { + title: 'Subtask 1.1', + task: 'not a function', + }, + ], + }, + ], + } + + const { error } = taskSchema.validate(invalidTask) + expect(error).not.toBe(undefined) + }) +})