/// import { beforeEach, describe, expect, it, jest } from "@jest/globals"; import type { SpyInstance } from "jest-mock"; import fs from "node:fs"; import type { Config } from "../../types"; import loadConfig, { overrideKeysFromCLI, readConfigFile, saveConfigToFile, validateConfig, writeConfigFile, } from "../config"; import { DEFAULT_DEFAULT_CONFIG_FILE_NAME } from "../constants"; import * as inquirer from "../inquirer"; import { logger } from "../logger"; const mockConfig = { debug: false, destination: "./docs", source: "./collections", excludes: [], force: false, logOptions: { silent: false, verbose: false, }, }; jest.mock("node:fs", () => ({ promises: { readFile: jest.fn(), writeFile: jest.fn(), }, existsSync: jest.fn(), readFileSync: jest.fn(), writeFileSync: jest.fn(), })); const mockedFs = jest.mocked(fs, { shallow: false }); // const mockedFs = fs as jest.Mocked; jest.mock("node:fs"); jest.mock("../logger", () => ({ logger: { info: jest.fn(), warn: jest.fn(), error: jest.fn(), debug: jest.fn(), verbose: jest.fn(), }, })); jest.mock("../inquirer"); let mockExit: SpyInstance; let mockConsoleLog: SpyInstance; describe("loadConfig", () => { beforeEach(() => { jest.resetAllMocks(); mockedFs.existsSync.mockImplementation(() => false); jest.spyOn(console, "log").mockImplementation(() => {}); jest .spyOn(process, "exit") .mockImplementation((code?: string | number | null) => { throw new Error(`Process.exit called with code: ${code}`); }) as jest.Mock; mockExit = jest .spyOn(process, "exit") .mockImplementation((code?: string | number | null) => { throw new Error(`Process exited with code ${code}`); // biome-ignore lint/suspicious/noExplicitAny: }) as any; mockConsoleLog = jest.spyOn(console, "log").mockImplementation(() => {}); global.config = undefined; }); it("should return early if global config is already set", () => { // biome-ignore lint/suspicious/noExplicitAny: (global as any).config = { some: "config" }; loadConfig({}); expect(mockedFs.existsSync).not.toHaveBeenCalled(); }); it("should read the specified config file if it exists", () => { const mockConfig: Config = { source: "test-source", logOptions: { silent: true, verbose: false }, destination: "test-destination.md", excludes: [], }; mockedFs.existsSync.mockReturnValue(true); mockedFs.readFileSync.mockReturnValue(JSON.stringify(mockConfig)); loadConfig({ "config-file": "custom-config.json" }); expect(mockedFs.existsSync).toHaveBeenCalledWith( expect.stringContaining("custom-config.json"), ); expect(mockedFs.readFileSync).toHaveBeenCalledWith( "custom-config.json", "utf-8", ); // biome-ignore lint/suspicious/noExplicitAny: expect((global as any).config).toEqual(expect.objectContaining(mockConfig)); }); it("should read the default config file if specified file does not exist", () => { const mockConfig: Config = { source: "test-source", logOptions: { silent: true, verbose: false }, destination: "test-destination.md", excludes: [], }; (fs.existsSync as jest.Mock).mockReturnValue(false); (fs.readFileSync as jest.Mock).mockReturnValue(JSON.stringify(mockConfig)); loadConfig({}); expect(fs.readFileSync).toHaveBeenCalledWith( DEFAULT_DEFAULT_CONFIG_FILE_NAME, "utf-8", ); // biome-ignore lint/suspicious/noExplicitAny: expect((global as any).config).toEqual(expect.objectContaining(mockConfig)); }); it("should handle errors when reading the config file", () => { const mockError = new Error("Mock file read error"); (mockedFs.existsSync as jest.Mock).mockReturnValue(true); (mockedFs.readFileSync as jest.Mock).mockImplementation(() => { throw mockError; }); expect(() => readConfigFile("test-config.json")).toThrow( "Process exited with code 1", ); expect(logger.error).toHaveBeenCalledWith(mockError); expect(logger.warn).toHaveBeenCalledWith( "Error reading config file; see process.log for details.", ); }); it("should handle errors when reading the default config file", () => { const mockError = new Error("Mock file read error"); (mockedFs.existsSync as jest.Mock).mockReturnValue(false); (mockedFs.readFileSync as jest.Mock).mockImplementation(() => { throw mockError; }); expect(() => readConfigFile("bad-config.json")).toThrow( "Process exited with code 1", ); expect(logger.error).toHaveBeenCalledWith(mockError); expect(logger.warn).toHaveBeenCalledWith( "Error reading config file; see process.log for details.", ); }); it("should log warnings for unrecognized configuration keys and continue execution", () => { const config = { destination: "test-destination.md", source: "test-source", logOptions: {}, unknownKey: "value", }; validateConfig(config); expect(logger.warn).toHaveBeenCalledWith( expect.stringContaining("Unsupported key(s)"), ); expect(mockExit).not.toHaveBeenCalled(); }); it("should log errors and exit for missing keys", () => { const config = { source: "test-source", logOptions: {}, unknownKey: "value", }; expect(() => validateConfig(config)).toThrow(); expect(logger.error).toHaveBeenCalledWith( expect.stringContaining("destination is required"), ); expect(mockConsoleLog).toHaveBeenCalledWith( "Error validating config file; see process.log for details", ); expect(mockExit).toHaveBeenCalledWith(1); }); it("should log errors and exit for invalid keys", () => { const config = { destination: "test-destination.md", source: "test-source", logOptions: {}, unknownKey: "value", }; expect(() => validateConfig({ ...config, destination: 123 })).toThrow(); expect(logger.error).toHaveBeenCalledWith( expect.stringContaining("expected string"), ); expect(mockConsoleLog).toHaveBeenCalledWith( "Error validating config file; see process.log for details", ); expect(mockExit).toHaveBeenCalledWith(1); }); it("should set missing keys to default values", () => { const mockConfig: Config = { source: "test-source", logOptions: {}, destination: "test-destination.md", }; const expectedConfig: Config = { source: "test-source", logOptions: { silent: false, verbose: false }, destination: "test-destination.md", excludes: [], }; (fs.existsSync as jest.Mock).mockReturnValue(true); (fs.readFileSync as jest.Mock).mockReturnValue(JSON.stringify(mockConfig)); loadConfig({}); // biome-ignore lint/suspicious/noExplicitAny: expect((global as any).config).toEqual( expect.objectContaining(expectedConfig), ); }); it("should override config with CLI arguments", () => { const mockConfig: Config = { source: "test-source", logOptions: {}, destination: "test-destination.md", }; (fs.existsSync as jest.Mock).mockReturnValue(true); (fs.readFileSync as jest.Mock).mockReturnValue(JSON.stringify(mockConfig)); loadConfig({ destination: "cli-destination.md", verbose: true }); // biome-ignore lint/suspicious/noExplicitAny: expect((global as any).config).toEqual( expect.objectContaining({ source: "test-source", destination: "cli-destination.md", logOptions: { verbose: true, silent: false }, excludes: [], }), ); }); it("should exit process when configData is null", () => { const mockExit = jest .spyOn(process, "exit") .mockImplementation(() => undefined as never); const mockReadFileSync = jest .spyOn(fs, "readFileSync") .mockReturnValue("null"); const mockExistsSync = jest.spyOn(fs, "existsSync").mockReturnValue(true); readConfigFile("test-config.json"); expect(logger.error).toHaveBeenCalledWith("Problem reading config file."); expect(mockExit).toHaveBeenCalledWith(1); mockExit.mockRestore(); mockReadFileSync.mockRestore(); mockExistsSync.mockRestore(); }); }); describe("validateConfig", () => { beforeEach(() => { jest.resetAllMocks(); mockConsoleLog = jest.spyOn(console, "log").mockImplementation(() => {}); }); it("should log warning if both silent and verbose are true and continue", () => { const mockExit = jest .spyOn(process, "exit") .mockImplementation(() => undefined as never); const config = { destination: "test-destination.md", source: "test-source", logOptions: { verbose: true, silent: true, }, }; validateConfig(config); expect(logger.warn).toHaveBeenCalledWith( "Verbose and silent are mutually exclusive; ignoring both.", ); expect(mockExit).not.toHaveBeenCalled(); }); it("should log warnings for unrecognized configuration keys and continue execution", () => { const mockExit = jest .spyOn(process, "exit") .mockImplementation(() => undefined as never); const config = { destination: "test-destination.md", source: "test-source", logOptions: {}, unknownKey: "value", }; validateConfig(config); expect(logger.warn).toHaveBeenCalledWith( expect.stringContaining("Unsupported key(s)"), ); expect(mockExit).not.toHaveBeenCalled(); }); it("should log errors and exit for missing keys", () => { const mockExit = jest .spyOn(process, "exit") .mockImplementation(() => undefined as never); mockConsoleLog = jest.spyOn(console, "log").mockImplementation(() => {}); const config = { source: "test-source", logOptions: {}, unknownKey: "value", }; validateConfig(config); expect(logger.error).toHaveBeenCalledWith( expect.stringContaining("destination is required"), ); expect(mockConsoleLog).toHaveBeenCalledWith( "Error validating config file; see process.log for details", ); expect(mockExit).toHaveBeenCalledWith(1); }); it("should log errors and exit for invalid keys", () => { const mockExit = jest .spyOn(process, "exit") .mockImplementation(() => undefined as never); const config = { destination: "test-destination.md", source: "test-source", logOptions: {}, unknownKey: "value", }; validateConfig({ ...config, destination: 123 }); expect(logger.error).toHaveBeenCalledWith( expect.stringContaining("expected string"), ); expect(mockConsoleLog).toHaveBeenCalledWith( "Error validating config file; see process.log for details", ); expect(mockExit).toHaveBeenCalledWith(1); }); }); describe("overrideKeysFromCLI", () => { it("should override config with CLI arguments", () => { const config: Config = { source: "original-source", destination: "original-destination.md", header: "original-header", tail: "original-tail", excludes: [], logOptions: { silent: false, verbose: false }, }; const argv = { source: "cli-source", destination: "cli-destination.md", silent: true, verbose: true, }; const result = overrideKeysFromCLI(config, argv); expect(result).toEqual({ source: "cli-source", destination: "cli-destination.md", header: "original-header", tail: "original-tail", excludes: [], logOptions: { silent: true, verbose: true }, }); }); it("should not override config keys that are not in CLI arguments", () => { const config: Config = { source: "original-source", destination: "original-destination.md", header: "original-header", tail: "original-tail", excludes: [], logOptions: { silent: false, verbose: false }, }; const argv = { source: "cli-source", }; const result = overrideKeysFromCLI(config, argv); expect(result).toEqual({ source: "cli-source", destination: "original-destination.md", header: "original-header", tail: "original-tail", excludes: [], logOptions: { silent: false, verbose: false }, }); }); }); describe("saveConfigToFile", () => { let mockReadFile: jest.Mock; mockedFs.writeFileSync = jest.fn(); beforeEach(() => { mockedFs.promises.writeFile = jest.fn(); jest.resetAllMocks(); mockedFs.existsSync.mockReturnValue(true); mockedFs.readFileSync.mockReturnValue(JSON.stringify(mockConfig)); jest.spyOn(inquirer, "saveConfig").mockResolvedValue(true); mockExit = jest .spyOn(process, "exit") .mockImplementation((code?: string | number | null) => { throw new Error(`Process.exit called with code: ${code}`); // biome-ignore lint/suspicious/noExplicitAny: }) as any; mockConsoleLog = jest.spyOn(console, "log").mockImplementation(() => {}); global.config = undefined; mockReadFile = jest.fn(); global.config = { destination: "test.md", source: "source", logOptions: {}, }; }); it("should return if the is no change to the configuration", async () => { mockedFs.existsSync.mockReturnValue(true); mockedFs.readFileSync.mockReturnValue(JSON.stringify(global.config)); await saveConfigToFile({ "config-file": "bruno-doc.config.json" }); expect(mockedFs.writeFileSync).not.toHaveBeenCalled(); }); it("should throw error if read fails", async () => { // mockConsoleLog.mockRestore(); mockedFs.existsSync.mockReturnValue(true); mockedFs.readFileSync.mockImplementation(() => { throw new Error("Failed to read file"); }); await expect( saveConfigToFile({ "config-file": "new_config.json" }), ).rejects.toThrow(); expect(logger.error).toHaveBeenCalledWith( expect.stringMatching(/^Problem reading/), ); }); it("should create a new config file if it doesn't exist", async () => { mockedFs.existsSync.mockReturnValue(false); const saveConfigSpy = jest .spyOn(inquirer, "saveConfig") .mockResolvedValue(true); await saveConfigToFile({ "config-file": "new_config.json" }); expect(saveConfigSpy).toHaveBeenCalled(); expect(saveConfigSpy.mock.results[0].value).resolves.toBe(true); expect(mockedFs.writeFileSync).toHaveBeenCalledWith( "new_config.json", JSON.stringify(global.config, null, 2), "utf8", ); expect(logger.info).toHaveBeenCalledWith("Configuration file updated!"); }); it("should update existing config file with merged data", async () => { mockedFs.promises.writeFile = jest.fn(); mockedFs.existsSync.mockReturnValue(true); mockedFs.readFileSync.mockReturnValue(JSON.stringify(mockConfig)); const saveConfigSpy = jest .spyOn(inquirer, "saveConfig") .mockResolvedValue(true); global.config = { destination: "test.md", source: "source", logOptions: {}, }; await saveConfigToFile({ "config-file": "bruno-doc.config.json" }); expect(saveConfigSpy).toHaveBeenCalled(); expect(saveConfigSpy.mock.results[0].value).resolves.toBe(true); expect(mockedFs.readFileSync).toHaveBeenCalledWith( "bruno-doc.config.json", "utf8", ); expect(mockedFs.writeFileSync).toHaveBeenCalledWith( "bruno-doc.config.json", JSON.stringify({ ...mockConfig, ...global.config }, null, 2), "utf8", ); expect(logger.info).toHaveBeenCalledWith("Configuration file updated!"); }); it("should handle write errors", async () => { mockReadFile.mockImplementation( (_path: unknown, _encoding: unknown, callback: unknown) => { (callback as (err: NodeJS.ErrnoException | null, data: string) => void)( null, "{}", ); }, ); const writeError = new Error("Process exited with code 1"); const mockConsoleError = jest .spyOn(console, "error") .mockImplementation(() => {}); mockedFs.writeFileSync.mockImplementation(() => { throw writeError; }); await expect( saveConfigToFile({ "config-file": "bruno-doc.config.json" }), ).rejects.toThrow("Process exited with code 1"); expect(mockConsoleError).toHaveBeenCalledWith({ err: writeError }); expect(logger.error).toHaveBeenCalledWith( "Error saving configuration file to 'bruno-doc.config.json'", ); expect(logger.error).toHaveBeenCalledWith(writeError); }); it("should use custom config file name when provided", async () => { mockedFs.existsSync.mockReturnValue(true); mockedFs.readFileSync.mockReturnValue(JSON.stringify(mockConfig)); mockedFs.writeFileSync.mockImplementation(() => {}); const customConfigFile = "test-config-file"; await saveConfigToFile({ "config-file": "test-config-file" }); expect(mockedFs.readFileSync).toHaveBeenCalled(); expect(mockedFs.writeFileSync).toHaveBeenCalledWith( customConfigFile, expect.any(String), "utf8", ); }); }); describe("writeConfigFile", () => { beforeEach(() => { jest.resetAllMocks(); mockedFs.promises.writeFile.mockReset(); global.config = { destination: "test.md", source: "source", logOptions: { silent: false, verbose: false }, }; }); it("should write config data to file with proper formatting", async () => { await writeConfigFile({ "config-file": "test-config.json" }); expect(mockedFs.promises.writeFile).toHaveBeenCalledWith( "test-config.json", JSON.stringify(global.config, null, 2), "utf8", ); expect(logger.info).toHaveBeenCalledWith("Configuration file updated!"); }); it("should log verbose output when verbose logging is enabled", async () => { global.config = { destination: "test.md", source: "source", logOptions: { verbose: true, }, }; await writeConfigFile({ "config-file": "test-config.json" }); expect(logger.verbose).toHaveBeenCalledWith( JSON.stringify(global.config, null, 2), ); }); it("should handle write errors and rethrow them", async () => { const writeError = new Error("Write failed"); mockedFs.promises.writeFile.mockRejectedValue(writeError); await expect( writeConfigFile({ "config-file": "test-config.json" }), ).rejects.toThrow(writeError); expect(logger.error).toHaveBeenCalledWith( "Error saving configuration file to 'test-config.json'", ); expect(logger.error).toHaveBeenCalledWith(writeError); }); });