Skip to content
Open
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
1 change: 1 addition & 0 deletions .c8rc.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
"all": true,
"exclude": [
"eslint.config.mjs",
"**/*.test.mjs",
"**/fixtures",
"src/generators/legacy-html/assets",
"src/generators/web/ui",
Expand Down
79 changes: 79 additions & 0 deletions bin/__tests__/cli.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import assert from 'node:assert/strict';
import { describe, it, mock } from 'node:test';

const logger = {
setLogLevel: mock.fn(),
};

describe('bin/cli', () => {
it('builds a program with commands/options and runs preAction hook', async () => {
const action = mock.fn(async () => {});

const commands = [
{
name: 'mycmd',
description: 'My command',
options: {
requiredText: {
flags: ['--required-text <value>'],
desc: 'Required option',
prompt: { type: 'text', required: true },
},
multi: {
flags: ['--multi <values...>'],
desc: 'Multi option',
prompt: {
type: 'multiselect',
options: [{ value: 'a' }, { value: 'b' }],
initialValue: ['a'],
},
},
},
action,
},
];

const { createProgram } = await import('../cli.mjs');
const program = createProgram(commands, { loggerInstance: logger })
.exitOverride()
.configureOutput({
writeOut: () => {},
writeErr: () => {},
});

// Global option should be present
const logLevelOpt = program.options.find(
o => o.attributeName() === 'logLevel'
);
assert.ok(logLevelOpt);

// Command and its options should be registered
const mycmd = program.commands.find(c => c.name() === 'mycmd');
assert.ok(mycmd);

const requiredOpt = mycmd.options.find(
o => o.attributeName() === 'requiredText'
);
assert.ok(requiredOpt);
assert.equal(requiredOpt.mandatory, true);

const multiOpt = mycmd.options.find(o => o.attributeName() === 'multi');
assert.ok(multiOpt);
assert.deepEqual(multiOpt.argChoices, ['a', 'b']);

await program.parseAsync([
'node',
'cli',
'--log-level',
'debug',
'mycmd',
'--required-text',
'hello',
'--multi',
'a',
]);

assert.equal(logger.setLogLevel.mock.callCount(), 1);
assert.equal(action.mock.callCount(), 1);
});
});
44 changes: 44 additions & 0 deletions bin/__tests__/utils.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import assert from 'node:assert/strict';
import process from 'node:process';
import { describe, it, mock } from 'node:test';

const logger = {
error: mock.fn(),
};

mock.module('../../src/logger/index.mjs', {
defaultExport: logger,
});

const { errorWrap } = await import('../utils.mjs');

describe('bin/utils - errorWrap', () => {
it('returns wrapped result for sync functions', async () => {
const wrapped = errorWrap((a, b) => a + b);
const result = await wrapped(1, 2);
assert.equal(result, 3);
});

it('returns wrapped result for async functions', async () => {
const wrapped = errorWrap(async a => a * 2);
const result = await wrapped(4);
assert.equal(result, 8);
});

it('logs and exits when the wrapped function throws', async t => {
const exit = t.mock.method(process, 'exit');
exit.mock.mockImplementation(() => {});

const err = new Error('boom');
const wrapped = errorWrap(() => {
throw err;
});

await wrapped('x');

assert.equal(logger.error.mock.callCount(), 1);
assert.equal(logger.error.mock.calls[0].arguments[0], err);
assert.equal(exit.mock.callCount(), 1);
assert.equal(exit.mock.calls[0].arguments[0], 1);
});
});
81 changes: 53 additions & 28 deletions bin/cli.mjs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
#!/usr/bin/env node

import process from 'node:process';
import { pathToFileURL } from 'node:url';

import { Command, Option } from 'commander';

Expand All @@ -9,40 +10,64 @@ import { errorWrap } from './utils.mjs';
import { LogLevel } from '../src/logger/constants.mjs';
import logger from '../src/logger/index.mjs';

const logLevelOption = new Option('--log-level <level>', 'Log level')
.choices(Object.keys(LogLevel))
.default('info');
/**
*
* @param commandsList
* @param root0
* @param root0.loggerInstance
*/
export const createProgram = (
commandsList = commands,
{ loggerInstance = logger } = {}
) => {
const logLevelOption = new Option('--log-level <level>', 'Log level')
.choices(Object.keys(LogLevel))
.default('info');

const program = new Command()
.name('@nodejs/doc-kit')
.description('CLI tool to generate the Node.js API documentation')
.addOption(logLevelOption)
.hook('preAction', cmd => logger.setLogLevel(cmd.opts().logLevel));
const program = new Command()
.name('@nodejs/doc-kit')
.description('CLI tool to generate the Node.js API documentation')
.addOption(logLevelOption)
.hook('preAction', cmd => loggerInstance.setLogLevel(cmd.opts().logLevel));

// Registering commands
commands.forEach(({ name, description, options, action }) => {
const cmd = program.command(name).description(description);
// Registering commands
commandsList.forEach(({ name, description, options, action }) => {
const cmd = program.command(name).description(description);

// Add options to the command
Object.values(options).forEach(({ flags, desc, prompt }) => {
const option = new Option(flags.join(', '), desc).default(
prompt.initialValue
);
// Add options to the command
Object.values(options).forEach(({ flags, desc, prompt }) => {
const option = new Option(flags.join(', '), desc).default(
prompt.initialValue
);

if (prompt.required) {
option.makeOptionMandatory();
}
if (prompt.required) {
option.makeOptionMandatory();
}

if (prompt.type === 'multiselect') {
option.choices(prompt.options.map(({ value }) => value));
}
if (prompt.type === 'multiselect') {
option.choices(prompt.options.map(({ value }) => value));
}

cmd.addOption(option);
cmd.addOption(option);
});

// Set the action for the command
cmd.action(errorWrap(action));
});

// Set the action for the command
cmd.action(errorWrap(action));
});
return program;
};

/**
*
* @param argv
*/
export const main = (argv = process.argv) => createProgram().parse(argv);

// Parse and execute command-line arguments
program.parse(process.argv);
// Parse and execute command-line arguments only when executed directly
if (
process.argv[1] &&
import.meta.url === pathToFileURL(process.argv[1]).href
) {
main();
}
87 changes: 87 additions & 0 deletions bin/commands/__tests__/generate.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import assert from 'node:assert/strict';
import { resolve } from 'node:path';
import { describe, it, mock } from 'node:test';

const runGenerators = mock.fn(async () => {});

mock.module('../../../src/generators.mjs', {
defaultExport: () => ({ runGenerators }),
});

mock.module('../../../src/parsers/markdown.mjs', {
namedExports: {
parseChangelog: async () => [{ version: 'v1.0.0', lts: false }],
parseIndex: async () => [{ section: 'fs', api: 'fs' }],
},
});

mock.module('../../../src/parsers/json.mjs', {
namedExports: {
parseTypeMap: async () => ({ Foo: 'foo.html' }),
},
});

const logger = {
debug: mock.fn(),
};

mock.module('../../../src/logger/index.mjs', {
defaultExport: logger,
});

mock.module('semver', {
namedExports: {
coerce: v => ({ raw: v, major: 1, minor: 2, patch: 3 }),
},
});

// Ensure the prompt option label builder (map callback) runs during module load.
mock.module('../../../src/generators/index.mjs', {
namedExports: {
publicGenerators: {
web: { name: 'web', version: '1.2.3', description: 'Web output' },
},
},
});

const cmd = (await import('../generate.mjs')).default;

describe('bin/commands/generate', () => {
it('calls runGenerators with normalized options', async () => {
await cmd.action({
target: ['web'],
input: ['doc/api/*.md'],
ignore: ['**/deprecated/**'],
output: 'out',
version: 'v20.0.0',
changelog: 'CHANGELOG.md',
gitRef: 'https://example.test/ref',
threads: '0',
chunkSize: 'not-a-number',
index: 'doc/api/index.md',
typeMap: 'doc/api/type_map.json',
});

assert.equal(logger.debug.mock.callCount(), 2);
assert.equal(runGenerators.mock.callCount(), 1);

const args = runGenerators.mock.calls[0].arguments[0];

assert.deepEqual(args.generators, ['web']);
assert.deepEqual(args.input, ['doc/api/*.md']);
assert.deepEqual(args.ignore, ['**/deprecated/**']);
assert.equal(args.output, resolve('out'));

// coerce() mocked: returns object with raw
assert.equal(args.version.raw, 'v20.0.0');

// min thread/chunkSize should be 1 when parseInt fails or < 1
assert.equal(args.threads, 1);
assert.equal(args.chunkSize, 1);

assert.equal(args.gitRef, 'https://example.test/ref');
assert.deepEqual(args.releases, [{ version: 'v1.0.0', lts: false }]);
assert.deepEqual(args.index, [{ section: 'fs', api: 'fs' }]);
assert.deepEqual(args.typeMap, { Foo: 'foo.html' });
});
});
Loading
Loading