Skip to content
Merged
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
85 changes: 35 additions & 50 deletions js/utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,11 @@ const discoveredPositionsJSFilename = "js/positions.js";

const { styleText } = require("node:util");
const Log = require("logger");
const Ajv = require("ajv");
const globals = require("globals");
const { Linter } = require("eslint");
const { getConfigFilePath } = require("#server_functions");

const linter = new Linter({ configType: "flat" });
const ajv = new Ajv();

const requireFromString = (src) => {
const m = new module.constructor();
Expand Down Expand Up @@ -227,67 +225,54 @@ const checkConfigFile = (configObject) => {
};

/**
* Validates the modules array in the config object.
* Checks that:
* - `modules` is an array
* - every entry has a `module` property of type string
* - every entry's `position` (if set) is a known region from index.html
*
* @param {string} data - The content of the configuration file to validate.
* Unknown positions produce a warning; structural errors are fatal.
* @param {object} data - The full config object to validate.
*/
const validateModulePositions = (data) => {
Log.info("Checking modules structure configuration ...");

const positionList = getModulePositions();

// Make Ajv schema configuration of modules config
// Only scan "module" and "position"
const schema = {
type: "object",
properties: {
modules: {
type: "array",
items: {
type: "object",
properties: {
module: {
type: "string"
},
position: {
type: "string"
}
},
required: ["module"]
}
}
}
};
// `modules` always exists (defaults.js provides a default array), but guard against it being overridden with a non-array value
if (data.modules !== undefined && !Array.isArray(data.modules)) {
Log.error("This module configuration contains errors:\nmodules must be an array");
process.exit(1);
}

// Scan all modules
const validate = ajv.compile(schema);
// Validate each module entry
for (const [index, mod] of (data.modules ?? []).entries()) {
// Each module entry must be an object so we can safely inspect its fields
if (mod === null || typeof mod !== "object" || Array.isArray(mod)) {
Log.error(`This module configuration contains errors:\n${JSON.stringify(mod, null, 2)}\nmodule entry must be an object`);
process.exit(1);
}

const valid = validate(data);
if (valid) {
Log.info(styleText("green", "Your modules structure configuration doesn't contain errors :)"));
// `module` (the module name) is required and must be a string
if (typeof mod.module !== "string") {
Log.error(`This module configuration contains errors:\n${JSON.stringify(mod, null, 2)}\nmodule: must be a string`);
process.exit(1);
}

// Check for unknown positions (warning only, not an error)
if (data.modules) {
for (const [index, module] of data.modules.entries()) {
if (module.position && !positionList.includes(module.position)) {
Log.warn(`Module ${index} ("${module.module}") uses unknown position: "${module.position}"`);
Log.warn(`Known positions are: ${positionList.join(", ")}`);
}
}
// `position` is optional, but must be a string when provided
if (mod.position !== undefined && typeof mod.position !== "string") {
Log.error(`This module configuration contains errors:\n${JSON.stringify(mod, null, 2)}\nposition: must be a string`);
process.exit(1);
}
} else {
const module = validate.errors[0].instancePath.split("/")[2];
const position = validate.errors[0].instancePath.split("/")[3];
let errorMessage = "This module configuration contains errors:";
errorMessage += `\n${JSON.stringify(data.modules[module], null, 2)}`;
if (position) {
errorMessage += `\n${position}: ${validate.errors[0].message}`;
errorMessage += `\n${JSON.stringify(validate.errors[0].params.allowedValues, null, 2).slice(1, -1)}`;
} else {
errorMessage += validate.errors[0].message;

// `position` is optional, but when set it must match a known region
if (mod.position && !positionList.includes(mod.position)) {
Log.warn(`Module ${index} ("${mod.module}") uses unknown position: "${mod.position}"`);
Log.warn(`Known positions are: ${positionList.join(", ")}`);
}
Log.error(errorMessage);
process.exit(1);
}

Log.info(styleText("green", "Your modules structure configuration doesn't contain errors :)"));
};

module.exports = { loadConfig, getModulePositions, moduleHasValidPosition, getAvailableModulePositions, checkConfigFile };
6 changes: 4 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 0 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,6 @@
"@fontsource/roboto": "^5.2.10",
"@fontsource/roboto-condensed": "^5.2.8",
"@fortawesome/fontawesome-free": "^7.2.0",
"ajv": "^8.20.0",
"animate.css": "^4.1.1",
"croner": "^10.0.1",
"eslint": "^10.3.0",
Expand Down
83 changes: 83 additions & 0 deletions tests/unit/classes/utils_spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
const fs = require("node:fs");

const Log = require("../../../js/logger");
const { checkConfigFile } = require("../../../js/utils");

const createConfigObject = (modules) => ({
configFilename: "config.js",
configContentFull: "module.exports = { modules: [] };",
fullConf: { modules }
});

const runCheck = (modules) => {
checkConfigFile(createConfigObject(modules));
};

const expectExitForModules = (modules) => {
vi.spyOn(process, "exit").mockImplementation((code) => {
throw new Error(`process.exit:${code}`);
});

expect(() => {
runCheck(modules);
}).toThrow("process.exit:1");
};

describe("utils", () => {
let originalReadFileSync;

beforeEach(() => {
originalReadFileSync = fs.readFileSync;

vi.spyOn(fs, "readFileSync").mockImplementation((fileName, ...args) => {
if (fileName === "index.html") {
return "<div class=\"region top_bar\"></div>\n<div class=\"region lower_third\"></div>";
}

return originalReadFileSync.call(fs, fileName, ...args);
});

vi.spyOn(fs, "writeFileSync").mockImplementation(() => {});
vi.spyOn(Log, "info").mockImplementation(() => {});
vi.spyOn(Log, "warn").mockImplementation(() => {});
vi.spyOn(Log, "error").mockImplementation(() => {});
});

afterEach(() => {
vi.restoreAllMocks();
});

it("accepts valid module entries", () => {
expect(() => {
runCheck([
{ module: "clock", position: "top_bar" },
{ module: "newsfeed" }
]);
}).not.toThrow();
expect(Log.error).not.toHaveBeenCalled();
});

it("exits when modules is not an array", () => {
expectExitForModules("not-an-array");
expect(Log.error).toHaveBeenCalledWith("This module configuration contains errors:\nmodules must be an array");
});

it("exits when module field is missing or not a string", () => {
expectExitForModules([{ module: 123, position: "top_bar" }]);
expect(Log.error).toHaveBeenCalled();
expect(Log.error.mock.calls[0][0]).toContain("module: must be a string");
});

it("warns for unknown positions without exiting", () => {
const exitSpy = vi.spyOn(process, "exit").mockImplementation((code) => {
throw new Error(`process.exit:${code}`);
});

expect(() => {
runCheck([{ module: "clock", position: "made_up_region" }]);
}).not.toThrow();
expect(exitSpy).not.toHaveBeenCalled();
expect(Log.warn).toHaveBeenCalled();
expect(Log.warn.mock.calls[0][0]).toContain("uses unknown position");
});
});
Loading