Skip to content

Commit 28d7365

Browse files
WIP
1 parent f7adbc0 commit 28d7365

File tree

22 files changed

+1879
-201
lines changed

22 files changed

+1879
-201
lines changed

.c8rc.json

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@
22
"all": true,
33
"exclude": [
44
"eslint.config.mjs",
5-
"**/legacy-html/**",
65
"**/*.test.mjs",
76
"**/fixtures",
87
"src/generators/legacy-html/assets",

bin/__tests__/cli.test.mjs

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
import assert from 'node:assert/strict';
2+
import { describe, it, mock } from 'node:test';
3+
4+
const logger = {
5+
setLogLevel: mock.fn(),
6+
};
7+
8+
describe('bin/cli', () => {
9+
it('builds a program with commands/options and runs preAction hook', async () => {
10+
const action = mock.fn(async () => {});
11+
12+
const commands = [
13+
{
14+
name: 'mycmd',
15+
description: 'My command',
16+
options: {
17+
requiredText: {
18+
flags: ['--required-text <value>'],
19+
desc: 'Required option',
20+
prompt: { type: 'text', required: true },
21+
},
22+
multi: {
23+
flags: ['--multi <values...>'],
24+
desc: 'Multi option',
25+
prompt: {
26+
type: 'multiselect',
27+
options: [{ value: 'a' }, { value: 'b' }],
28+
initialValue: ['a'],
29+
},
30+
},
31+
},
32+
action,
33+
},
34+
];
35+
36+
const { createProgram } = await import('../cli.mjs');
37+
const program = createProgram(commands, { loggerInstance: logger })
38+
.exitOverride()
39+
.configureOutput({
40+
writeOut: () => {},
41+
writeErr: () => {},
42+
});
43+
44+
// Global option should be present
45+
const logLevelOpt = program.options.find(
46+
o => o.attributeName() === 'logLevel'
47+
);
48+
assert.ok(logLevelOpt);
49+
50+
// Command and its options should be registered
51+
const mycmd = program.commands.find(c => c.name() === 'mycmd');
52+
assert.ok(mycmd);
53+
54+
const requiredOpt = mycmd.options.find(
55+
o => o.attributeName() === 'requiredText'
56+
);
57+
assert.ok(requiredOpt);
58+
assert.equal(requiredOpt.mandatory, true);
59+
60+
const multiOpt = mycmd.options.find(o => o.attributeName() === 'multi');
61+
assert.ok(multiOpt);
62+
assert.deepEqual(multiOpt.argChoices, ['a', 'b']);
63+
64+
await program.parseAsync([
65+
'node',
66+
'cli',
67+
'--log-level',
68+
'debug',
69+
'mycmd',
70+
'--required-text',
71+
'hello',
72+
'--multi',
73+
'a',
74+
]);
75+
76+
assert.equal(logger.setLogLevel.mock.callCount(), 1);
77+
assert.equal(action.mock.callCount(), 1);
78+
});
79+
});

bin/__tests__/utils.test.mjs

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import assert from 'node:assert/strict';
2+
import process from 'node:process';
3+
import { describe, it, mock } from 'node:test';
4+
5+
const logger = {
6+
error: mock.fn(),
7+
};
8+
9+
mock.module('../../src/logger/index.mjs', {
10+
defaultExport: logger,
11+
});
12+
13+
const { errorWrap } = await import('../utils.mjs');
14+
15+
describe('bin/utils - errorWrap', () => {
16+
it('returns wrapped result for sync functions', async () => {
17+
const wrapped = errorWrap((a, b) => a + b);
18+
const result = await wrapped(1, 2);
19+
assert.equal(result, 3);
20+
});
21+
22+
it('returns wrapped result for async functions', async () => {
23+
const wrapped = errorWrap(async a => a * 2);
24+
const result = await wrapped(4);
25+
assert.equal(result, 8);
26+
});
27+
28+
it('logs and exits when the wrapped function throws', async t => {
29+
const exit = t.mock.method(process, 'exit');
30+
exit.mock.mockImplementation(() => {});
31+
32+
const err = new Error('boom');
33+
const wrapped = errorWrap(() => {
34+
throw err;
35+
});
36+
37+
await wrapped('x');
38+
39+
assert.equal(logger.error.mock.callCount(), 1);
40+
assert.equal(logger.error.mock.calls[0].arguments[0], err);
41+
assert.equal(exit.mock.callCount(), 1);
42+
assert.equal(exit.mock.calls[0].arguments[0], 1);
43+
});
44+
});

bin/cli.mjs

Lines changed: 53 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
#!/usr/bin/env node
22

33
import process from 'node:process';
4+
import { pathToFileURL } from 'node:url';
45

56
import { Command, Option } from 'commander';
67

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

12-
const logLevelOption = new Option('--log-level <level>', 'Log level')
13-
.choices(Object.keys(LogLevel))
14-
.default('info');
13+
/**
14+
*
15+
* @param commandsList
16+
* @param root0
17+
* @param root0.loggerInstance
18+
*/
19+
export const createProgram = (
20+
commandsList = commands,
21+
{ loggerInstance = logger } = {}
22+
) => {
23+
const logLevelOption = new Option('--log-level <level>', 'Log level')
24+
.choices(Object.keys(LogLevel))
25+
.default('info');
1526

16-
const program = new Command()
17-
.name('@nodejs/doc-kit')
18-
.description('CLI tool to generate the Node.js API documentation')
19-
.addOption(logLevelOption)
20-
.hook('preAction', cmd => logger.setLogLevel(cmd.opts().logLevel));
27+
const program = new Command()
28+
.name('@nodejs/doc-kit')
29+
.description('CLI tool to generate the Node.js API documentation')
30+
.addOption(logLevelOption)
31+
.hook('preAction', cmd => loggerInstance.setLogLevel(cmd.opts().logLevel));
2132

22-
// Registering commands
23-
commands.forEach(({ name, description, options, action }) => {
24-
const cmd = program.command(name).description(description);
33+
// Registering commands
34+
commandsList.forEach(({ name, description, options, action }) => {
35+
const cmd = program.command(name).description(description);
2536

26-
// Add options to the command
27-
Object.values(options).forEach(({ flags, desc, prompt }) => {
28-
const option = new Option(flags.join(', '), desc).default(
29-
prompt.initialValue
30-
);
37+
// Add options to the command
38+
Object.values(options).forEach(({ flags, desc, prompt }) => {
39+
const option = new Option(flags.join(', '), desc).default(
40+
prompt.initialValue
41+
);
3142

32-
if (prompt.required) {
33-
option.makeOptionMandatory();
34-
}
43+
if (prompt.required) {
44+
option.makeOptionMandatory();
45+
}
3546

36-
if (prompt.type === 'multiselect') {
37-
option.choices(prompt.options.map(({ value }) => value));
38-
}
47+
if (prompt.type === 'multiselect') {
48+
option.choices(prompt.options.map(({ value }) => value));
49+
}
3950

40-
cmd.addOption(option);
51+
cmd.addOption(option);
52+
});
53+
54+
// Set the action for the command
55+
cmd.action(errorWrap(action));
4156
});
4257

43-
// Set the action for the command
44-
cmd.action(errorWrap(action));
45-
});
58+
return program;
59+
};
60+
61+
/**
62+
*
63+
* @param argv
64+
*/
65+
export const main = (argv = process.argv) => createProgram().parse(argv);
4666

47-
// Parse and execute command-line arguments
48-
program.parse(process.argv);
67+
// Parse and execute command-line arguments only when executed directly
68+
if (
69+
process.argv[1] &&
70+
import.meta.url === pathToFileURL(process.argv[1]).href
71+
) {
72+
main();
73+
}
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
import assert from 'node:assert/strict';
2+
import { resolve } from 'node:path';
3+
import { describe, it, mock } from 'node:test';
4+
5+
const runGenerators = mock.fn(async () => {});
6+
7+
mock.module('../../../src/generators.mjs', {
8+
defaultExport: () => ({ runGenerators }),
9+
});
10+
11+
mock.module('../../../src/parsers/markdown.mjs', {
12+
namedExports: {
13+
parseChangelog: async () => [{ version: 'v1.0.0', lts: false }],
14+
parseIndex: async () => [{ section: 'fs', api: 'fs' }],
15+
},
16+
});
17+
18+
mock.module('../../../src/parsers/json.mjs', {
19+
namedExports: {
20+
parseTypeMap: async () => ({ Foo: 'foo.html' }),
21+
},
22+
});
23+
24+
const logger = {
25+
debug: mock.fn(),
26+
};
27+
28+
mock.module('../../../src/logger/index.mjs', {
29+
defaultExport: logger,
30+
});
31+
32+
mock.module('semver', {
33+
namedExports: {
34+
coerce: v => ({ raw: v, major: 1, minor: 2, patch: 3 }),
35+
},
36+
});
37+
38+
// Ensure the prompt option label builder (map callback) runs during module load.
39+
mock.module('../../../src/generators/index.mjs', {
40+
namedExports: {
41+
publicGenerators: {
42+
web: { name: 'web', version: '1.2.3', description: 'Web output' },
43+
},
44+
},
45+
});
46+
47+
const cmd = (await import('../generate.mjs')).default;
48+
49+
describe('bin/commands/generate', () => {
50+
it('calls runGenerators with normalized options', async () => {
51+
await cmd.action({
52+
target: ['web'],
53+
input: ['doc/api/*.md'],
54+
ignore: ['**/deprecated/**'],
55+
output: 'out',
56+
version: 'v20.0.0',
57+
changelog: 'CHANGELOG.md',
58+
gitRef: 'https://example.test/ref',
59+
threads: '0',
60+
chunkSize: 'not-a-number',
61+
index: 'doc/api/index.md',
62+
typeMap: 'doc/api/type_map.json',
63+
});
64+
65+
assert.equal(logger.debug.mock.callCount(), 2);
66+
assert.equal(runGenerators.mock.callCount(), 1);
67+
68+
const args = runGenerators.mock.calls[0].arguments[0];
69+
70+
assert.deepEqual(args.generators, ['web']);
71+
assert.deepEqual(args.input, ['doc/api/*.md']);
72+
assert.deepEqual(args.ignore, ['**/deprecated/**']);
73+
assert.equal(args.output, resolve('out'));
74+
75+
// coerce() mocked: returns object with raw
76+
assert.equal(args.version.raw, 'v20.0.0');
77+
78+
// min thread/chunkSize should be 1 when parseInt fails or < 1
79+
assert.equal(args.threads, 1);
80+
assert.equal(args.chunkSize, 1);
81+
82+
assert.equal(args.gitRef, 'https://example.test/ref');
83+
assert.deepEqual(args.releases, [{ version: 'v1.0.0', lts: false }]);
84+
assert.deepEqual(args.index, [{ section: 'fs', api: 'fs' }]);
85+
assert.deepEqual(args.typeMap, { Foo: 'foo.html' });
86+
});
87+
});

0 commit comments

Comments
 (0)