Skip to content

Commit fca9d9c

Browse files
admirjashariclaude
andcommitted
SP-1383: add module-handler discovery + builder tests to cover new-code lines
Sonar counts pre-existing module-handler code (discoverAndRegisterModules, argument) as new code for this PR due to the project's New Code Definition. Cover it with tests to clear the coverage gate. No source changes. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 5c19af6 commit fca9d9c

2 files changed

Lines changed: 163 additions & 0 deletions

File tree

Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
import * as fs from "fs";
2+
import { tmpdir } from "os";
3+
import { join, dirname } from "path";
4+
import { Command } from "commander";
5+
import { ModuleHandler } from "../../../src/core/command/module-handler";
6+
import { testContext } from "../../utls/test-context";
7+
import { loggingTestTransport } from "../../jest.setup";
8+
9+
const createdRoots: string[] = [];
10+
11+
function createCommandsTree(files: Record<string, string>): string {
12+
const root = fs.mkdtempSync(join(tmpdir(), "mh-discovery-"));
13+
createdRoots.push(root);
14+
fs.mkdirSync(join(root, "commands"));
15+
for (const [relPath, content] of Object.entries(files)) {
16+
const full = join(root, "commands", relPath);
17+
fs.mkdirSync(dirname(full), { recursive: true });
18+
fs.writeFileSync(full, content);
19+
}
20+
return root;
21+
}
22+
23+
function newHandler(): ModuleHandler {
24+
return new ModuleHandler(new Command(), testContext);
25+
}
26+
27+
function messages(): string[] {
28+
return loggingTestTransport.logMessages.map(entry => String(entry.message));
29+
}
30+
31+
describe("ModuleHandler.discoverAndRegisterModules", () => {
32+
let exitSpy: jest.SpyInstance;
33+
34+
beforeEach(() => {
35+
loggingTestTransport.logMessages = [];
36+
// The logger's transport calls process.exit on error-level logs; stub it so
37+
// the error branches can be exercised without killing the test runner.
38+
exitSpy = jest.spyOn(process, "exit").mockImplementation((() => undefined) as never);
39+
});
40+
41+
afterEach(() => {
42+
exitSpy.mockRestore();
43+
});
44+
45+
afterAll(() => {
46+
for (const root of createdRoots) {
47+
fs.rmSync(root, { recursive: true, force: true });
48+
}
49+
});
50+
51+
it("registers a module that exports a class with a register method", () => {
52+
const root = createCommandsTree({ "valid/module.js": "module.exports = class { register() {} };" });
53+
const handler = newHandler();
54+
55+
handler.discoverAndRegisterModules(root, false);
56+
57+
expect(handler.registeredModules).toHaveLength(1);
58+
});
59+
60+
it("skips a folder that has no module entry point", () => {
61+
const root = createCommandsTree({ "empty/readme.txt": "no module here" });
62+
const handler = newHandler();
63+
64+
handler.discoverAndRegisterModules(root, false);
65+
66+
expect(handler.registeredModules).toHaveLength(0);
67+
});
68+
69+
it("warns when the module export is not a class", () => {
70+
const root = createCommandsTree({ "notclass/module.js": "module.exports = { notAClass: true };" });
71+
const handler = newHandler();
72+
73+
handler.discoverAndRegisterModules(root, false);
74+
75+
expect(handler.registeredModules).toHaveLength(0);
76+
expect(messages().some(m => m.includes("is not a class/constructor function"))).toBe(true);
77+
});
78+
79+
it("warns when the module class has no register method", () => {
80+
const root = createCommandsTree({ "noregister/module.js": "module.exports = class {};" });
81+
const handler = newHandler();
82+
83+
handler.discoverAndRegisterModules(root, false);
84+
85+
expect(handler.registeredModules).toHaveLength(0);
86+
expect(messages().some(m => m.includes("does not have a 'register' method"))).toBe(true);
87+
});
88+
89+
it("warns when a module cannot be required due to a missing dependency", () => {
90+
const root = createCommandsTree({
91+
"badrequire/module.js": "require('a-package-that-does-not-exist-xyz');",
92+
});
93+
const handler = newHandler();
94+
95+
handler.discoverAndRegisterModules(root, false);
96+
97+
expect(handler.registeredModules).toHaveLength(0);
98+
expect(messages().some(m => m.includes("Could not require module"))).toBe(true);
99+
});
100+
101+
it("warns when requiring a module fails with an ENOENT error", () => {
102+
const root = createCommandsTree({
103+
"enoent/module.js": "require('fs').readFileSync('/no/such/path/xyz-content-cli-test');",
104+
});
105+
const handler = newHandler();
106+
107+
handler.discoverAndRegisterModules(root, false);
108+
109+
expect(handler.registeredModules).toHaveLength(0);
110+
expect(messages().some(m => m.includes("does not contain a compiled module.js file"))).toBe(true);
111+
});
112+
113+
it("logs an error when the commands path is not a directory", () => {
114+
const root = fs.mkdtempSync(join(tmpdir(), "mh-notdir-"));
115+
createdRoots.push(root);
116+
fs.writeFileSync(join(root, "commands"), "this is a file, not a directory");
117+
const handler = newHandler();
118+
119+
handler.discoverAndRegisterModules(root, false);
120+
121+
expect(handler.registeredModules).toHaveLength(0);
122+
expect(messages().some(m => m.includes("Failed to read modules directory"))).toBe(true);
123+
});
124+
125+
it("logs an error when requiring a module throws unexpectedly", () => {
126+
const root = createCommandsTree({ "throws/module.js": "throw new Error('boom');" });
127+
const handler = newHandler();
128+
129+
handler.discoverAndRegisterModules(root, false);
130+
131+
expect(handler.registeredModules).toHaveLength(0);
132+
expect(messages().some(m => m.includes("Error processing module in"))).toBe(true);
133+
});
134+
135+
it("logs an error when the commands directory does not exist", () => {
136+
const root = fs.mkdtempSync(join(tmpdir(), "mh-nocommands-"));
137+
createdRoots.push(root);
138+
const handler = newHandler();
139+
140+
handler.discoverAndRegisterModules(root, false);
141+
142+
expect(handler.registeredModules).toHaveLength(0);
143+
expect(messages().some(m => m.includes("Modules directory not found"))).toBe(true);
144+
});
145+
146+
it("looks for module.ts (not module.js) in dev mode", () => {
147+
const root = createCommandsTree({ "devonly/module.js": "module.exports = class { register() {} };" });
148+
const handler = newHandler();
149+
150+
// dev mode looks for module.ts, which is absent here, so the folder is skipped
151+
handler.discoverAndRegisterModules(root, true);
152+
153+
expect(handler.registeredModules).toHaveLength(0);
154+
});
155+
});

tests/core/command/module-handler.spec.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,3 +150,11 @@ describe("CommandConfig early access gating", () => {
150150
expect(handler).toHaveBeenCalled();
151151
});
152152
});
153+
154+
describe("CommandConfig builder methods", () => {
155+
it("supports the argument() builder", () => {
156+
const configurator = new Configurator(new Command(), testContext);
157+
158+
expect(() => configurator.command("with-arg").argument("<value>", "an argument")).not.toThrow();
159+
});
160+
});

0 commit comments

Comments
 (0)