diff --git a/packages/ts-interface-generator/src/generateTSInterfaces.ts b/packages/ts-interface-generator/src/generateTSInterfaces.ts index 0cba163..4c6fe52 100644 --- a/packages/ts-interface-generator/src/generateTSInterfaces.ts +++ b/packages/ts-interface-generator/src/generateTSInterfaces.ts @@ -5,10 +5,11 @@ import pkgJson from "../package.json"; import { Args, main } from "./generateTSInterfacesAPI"; import yargs from "yargs"; +import { hideBin } from "yargs/helpers"; // configure yargs with the cli options as launcher const version = `${pkgJson.version} (from ${__filename})`; -const appArgs = yargs() +const appArgs = yargs(hideBin(process.argv)) .version(version) .option({ config: { diff --git a/packages/ts-interface-generator/src/test/jsdocPreference.test.ts b/packages/ts-interface-generator/src/test/jsdocPreference.test.ts new file mode 100644 index 0000000..92f27e5 --- /dev/null +++ b/packages/ts-interface-generator/src/test/jsdocPreference.test.ts @@ -0,0 +1,307 @@ +import fs from "fs"; +import path from "path"; +import ts from "typescript"; +import log from "loglevel"; +import { execSync, execFileSync } from "child_process"; +import { generateInterfaces } from "../interfaceGenerationHelper"; +import { + getAllKnownGlobals, + GlobalToModuleMapping, +} from "../typeScriptEnvironment"; +import { getProgramInfo } from "../generateTSInterfacesAPI"; +import Preferences from "../preferences"; + +jest.setTimeout(30000); + +const testCasesDir = path.resolve(__dirname, "testcases"); + +const standardTsConfig: ts.CompilerOptions = { + target: ts.ScriptTarget.ES2022, + module: ts.ModuleKind.CommonJS, + strict: true, + moduleResolution: ts.ModuleResolutionKind.Node16, + esModuleInterop: true, + skipLibCheck: true, + forceConsistentCasingInFileNames: true, + types: ["openui5"], +}; + +function generateForTestCase(testCaseDir: string): Promise { + const config = { ...standardTsConfig, baseUrl: testCaseDir }; + const tsFiles = fs + .readdirSync(testCaseDir) + .filter((file) => file.endsWith(".ts") && !file.endsWith(".d.ts")) + .map((file) => path.join(testCaseDir, file)); + + const program = ts.createProgram(tsFiles, config); + const typeChecker = program.getTypeChecker(); + const programInfo = getProgramInfo(program, typeChecker); + const allKnownGlobals: GlobalToModuleMapping = + getAllKnownGlobals(typeChecker); + + const sourceFiles = program.getSourceFiles().filter((sourceFile) => { + return ( + !sourceFile.isDeclarationFile && + path.basename(sourceFile.fileName) !== "library.ts" + ); + }); + + return new Promise((resolve) => { + const resultProcessor = ( + _sourceFileName: string, + _className: string, + interfaceText: string, + ) => { + resolve(interfaceText); + }; + + generateInterfaces( + sourceFiles[0], + typeChecker, + Object.assign({}, programInfo.allKnownLocalExports, allKnownGlobals), + resultProcessor, + ); + }); +} + +/** + * Tests for issue #542: --jsdoc CLI parameter is not respected. + * + * Root cause: In commit bf53c43 (yargs v17→v18 upgrade), the CLI entry point was + * rewritten from the singleton pattern (yargs.option({...}).argv — which reads + * process.argv) to the factory pattern (yargs().option({...}).argv). The factory + * pattern creates a detached instance that does NOT read process.argv in either + * v17 or v18. The fix is to pass hideBin(process.argv) explicitly: + * yargs(hideBin(process.argv)).option({...}).argv + */ + +describe("JSDoc CLI argument parsing (root cause of issue #542)", () => { + test("yargs() without arguments ignores process.argv in v18 — returns default 'verbose'", () => { + const script = path.join(__dirname, "_yargs_test_no_hideBin.mjs"); + fs.writeFileSync( + script, + `import yargs from 'yargs'; +const argv = await yargs().option({ jsdoc: { choices: ['none','minimal','verbose'], default: 'verbose' } }).argv; +process.stdout.write(argv.jsdoc);`, + ); + try { + const result = execSync(`node ${script} --jsdoc minimal`, { + encoding: "utf-8", + }); + expect(result).toBe("verbose"); + } finally { + fs.unlinkSync(script); + } + }); + + test("yargs(hideBin(process.argv)) correctly parses --jsdoc minimal", () => { + const script = path.join(__dirname, "_yargs_test_with_hideBin.mjs"); + fs.writeFileSync( + script, + `import yargs from 'yargs'; +import { hideBin } from 'yargs/helpers'; +const argv = await yargs(hideBin(process.argv)).option({ jsdoc: { choices: ['none','minimal','verbose'], default: 'verbose' } }).argv; +process.stdout.write(argv.jsdoc);`, + ); + try { + const result = execSync(`node ${script} --jsdoc minimal`, { + encoding: "utf-8", + }); + expect(result).toBe("minimal"); + } finally { + fs.unlinkSync(script); + } + }); +}); + +describe("CLI end-to-end (issue #542)", () => { + const cliEntryPoint = path.resolve( + __dirname, + "../../dist/generateTSInterfaces.js", + ); + const testCaseDir = path.resolve( + __dirname, + "testcases/tsconfig-path-relative", + ); + const genFile = path.join(testCaseDir, "MyControl.gen.d.ts"); + + beforeAll(() => { + if (!fs.existsSync(cliEntryPoint)) { + execSync("npx tsc", { cwd: path.resolve(__dirname, "../..") }); + } + }); + + afterEach(() => { + // Restore original gen files + execSync("git checkout -- .", { cwd: testCaseDir }); + }); + + test("--jsdoc minimal produces output without boilerplate JSDoc", () => { + fs.unlinkSync(genFile); + execFileSync("node", [cliEntryPoint, "--jsdoc", "minimal"], { + cwd: testCaseDir, + }); + const output = fs.readFileSync(genFile, "utf-8"); + + expect(output).toContain("getMyJSEnumVal(): MyJSEnum;"); + expect(output).not.toContain("@returns"); + expect(output).not.toContain("@param"); + expect(output).not.toContain("Gets current value of property"); + }); + + test("--jsdoc none produces output without any method-level JSDoc", () => { + fs.unlinkSync(genFile); + execFileSync("node", [cliEntryPoint, "--jsdoc", "none"], { + cwd: testCaseDir, + }); + const output = fs.readFileSync(genFile, "utf-8"); + + expect(output).toContain("getMyJSEnumVal(): MyJSEnum;"); + expect(output).not.toContain("@returns"); + expect(output).not.toContain("@param"); + }); + + test("--jsdoc verbose produces output with full boilerplate JSDoc", () => { + fs.unlinkSync(genFile); + execFileSync("node", [cliEntryPoint, "--jsdoc", "verbose"], { + cwd: testCaseDir, + }); + const output = fs.readFileSync(genFile, "utf-8"); + + expect(output).toContain("@returns"); + expect(output).toContain("@param"); + expect(output).toContain('Gets current value of property "myJSEnumVal"'); + }); +}); + +describe("JSDoc preference modes", () => { + beforeAll(() => { + jest.spyOn(log, "warn").mockImplementation(() => {}); + }); + + afterAll(() => { + jest.restoreAllMocks(); + }); + + afterEach(() => { + Preferences.set({ jsdoc: "verbose" }); + }); + + const xlControlDir = path.join(testCasesDir, "xl-control-with-all-features"); + const simpleControlDir = path.join(testCasesDir, "simple-control"); + + describe("simple-control (no source JSDoc on properties)", () => { + test("verbose mode includes boilerplate JSDoc", async () => { + Preferences.set({ jsdoc: "verbose" }); + const result = await generateForTestCase(simpleControlDir); + + expect(result).toContain('Gets current value of property "text"'); + expect(result).toContain('@returns Value of property "text"'); + expect(result).toContain('Sets a new value for property "text"'); + expect(result).toContain('@param text New value for property "text"'); + expect(result).toContain( + '@returns Reference to "this" in order to allow method chaining', + ); + }); + + test("minimal mode omits boilerplate JSDoc for properties without source doc", async () => { + Preferences.set({ jsdoc: "minimal" }); + const result = await generateForTestCase(simpleControlDir); + + expect(result).not.toContain('Gets current value of property "text"'); + expect(result).not.toContain('@returns Value of property "text"'); + expect(result).not.toContain('@param text New value for property "text"'); + expect(result).not.toContain( + '@returns Reference to "this" in order to allow method chaining', + ); + }); + + test("none mode produces no method-level JSDoc comments", async () => { + Preferences.set({ jsdoc: "none" }); + const result = await generateForTestCase(simpleControlDir); + + expect(result).not.toContain('Gets current value of property "text"'); + expect(result).not.toContain('@returns Value of property "text"'); + expect(result).not.toContain("@param text"); + // The settings interface description comment is not gated by jsdoc preference (known behavior) + expect(result).toContain( + "Interface defining the settings object used in constructor calls", + ); + }); + }); + + describe("xl-control-with-all-features (has source JSDoc, @since, @experimental)", () => { + test("verbose mode includes both boilerplate and source JSDoc", async () => { + Preferences.set({ jsdoc: "verbose" }); + const result = await generateForTestCase(xlControlDir); + + // boilerplate + expect(result).toContain('Gets current value of property "subtext"'); + expect(result).toContain('@returns Value of property "subtext"'); + // source doc + expect(result).toContain("The text that appears below the main text."); + expect(result).toContain("@since 1.0"); + expect(result).toContain("@experimental"); + }); + + test("minimal mode keeps source JSDoc but removes boilerplate", async () => { + Preferences.set({ jsdoc: "minimal" }); + const result = await generateForTestCase(xlControlDir); + + // source doc and tags should still be present + expect(result).toContain("The text that appears below the main text."); + expect(result).toContain("@since 1.0"); + expect(result).toContain("@experimental"); + expect(result).toContain("Determines the text color of the"); + + // boilerplate should be absent + expect(result).not.toContain('Gets current value of property "subtext"'); + expect(result).not.toContain('@returns Value of property "subtext"'); + expect(result).not.toContain('Sets a new value for property "subtext"'); + expect(result).not.toContain( + '@param subtext New value for property "subtext"', + ); + expect(result).not.toContain('Attaches event handler "fn" to the'); + expect(result).not.toContain('Detaches event handler "fn" from the'); + expect(result).not.toContain( + 'Fires event "singlePress" to attached listeners.', + ); + }); + + test("none mode produces no method-level JSDoc comments", async () => { + Preferences.set({ jsdoc: "none" }); + const result = await generateForTestCase(xlControlDir); + + // Method-level JSDoc should be absent + expect(result).not.toContain("@returns Value of property"); + expect(result).not.toContain("@param subtext"); + expect(result).not.toContain("Gets current value of property"); + expect(result).not.toContain("Attaches event handler"); + // Source-level tags should also be absent + expect(result).not.toContain("@since"); + expect(result).not.toContain("@experimental"); + }); + + test("verbose and minimal produce different output", async () => { + Preferences.set({ jsdoc: "verbose" }); + const verbose = await generateForTestCase(xlControlDir); + + Preferences.set({ jsdoc: "minimal" }); + const minimal = await generateForTestCase(xlControlDir); + + expect(verbose).not.toEqual(minimal); + expect(verbose.length).toBeGreaterThan(minimal.length); + }); + + test("minimal and none produce different output", async () => { + Preferences.set({ jsdoc: "minimal" }); + const minimal = await generateForTestCase(xlControlDir); + + Preferences.set({ jsdoc: "none" }); + const none = await generateForTestCase(xlControlDir); + + expect(minimal).not.toEqual(none); + expect(minimal.length).toBeGreaterThan(none.length); + }); + }); +});