diff --git a/packages/builder/lib/lbt/analyzer/ComponentAnalyzer.js b/packages/builder/lib/lbt/analyzer/ComponentAnalyzer.js index 1f32bcb08c4..a11dae084f0 100644 --- a/packages/builder/lib/lbt/analyzer/ComponentAnalyzer.js +++ b/packages/builder/lib/lbt/analyzer/ComponentAnalyzer.js @@ -21,6 +21,7 @@ import {fromUI5LegacyName} from "../utils/ModuleName.js"; import {getLogger} from "@ui5/logger"; +import parseJSON from "../../utils/json.js"; const log = getLogger("lbt:analyzer:ComponentAnalyzer"); // --------------------------------------------------------------------------------------------------------- @@ -55,7 +56,7 @@ class ComponentAnalyzer { const manifestResource = await this._pool.findResource(manifestName).catch(() => null); if ( manifestResource ) { const fileContent = await manifestResource.buffer(); - this._analyzeManifest( JSON.parse(fileContent.toString()), info ); + this._analyzeManifest( parseJSON(fileContent.toString(), manifestName), info ); } else { log.verbose(`No manifest found for '${resource.name}', skipping analysis`); } diff --git a/packages/builder/lib/lbt/analyzer/SmartTemplateAnalyzer.js b/packages/builder/lib/lbt/analyzer/SmartTemplateAnalyzer.js index f33c86685ef..6afd4f29656 100644 --- a/packages/builder/lib/lbt/analyzer/SmartTemplateAnalyzer.js +++ b/packages/builder/lib/lbt/analyzer/SmartTemplateAnalyzer.js @@ -29,6 +29,7 @@ import SapUiDefine from "../calls/SapUiDefine.js"; import {parseJS, Syntax} from "../utils/parseUtils.js"; import {getValue, isMethodCall, getStringValue} from "../utils/ASTUtils.js"; import {getLogger} from "@ui5/logger"; +import parseJSON from "../../utils/json.js"; const log = getLogger("lbt:analyzer:SmartTemplateAnalyzer"); // --------------------------------------------------------------------------------------------------------- @@ -57,7 +58,7 @@ class TemplateComponentAnalyzer { const manifestResource = await this._pool.findResource(manifestName).catch(() => null); if ( manifestResource ) { const fileContent = await manifestResource.buffer(); - await this._analyzeManifest( JSON.parse(fileContent.toString()), info ); + await this._analyzeManifest( parseJSON(fileContent.toString(), manifestName), info ); } else { log.verbose(`No manifest found for '${resource.name}', skipping analysis`); } diff --git a/packages/builder/lib/processors/manifestEnhancer.js b/packages/builder/lib/processors/manifestEnhancer.js index 5f687f694d7..73202a633e6 100644 --- a/packages/builder/lib/processors/manifestEnhancer.js +++ b/packages/builder/lib/processors/manifestEnhancer.js @@ -3,6 +3,7 @@ const {SemVer: Version, lt} = semver; import {promisify} from "node:util"; import path from "node:path/posix"; import {getLogger} from "@ui5/logger"; +import parseJSON from "../utils/json.js"; const log = getLogger("builder:processors:manifestEnhancer"); const APP_DESCRIPTOR_V22 = new Version("1.21.0"); @@ -180,7 +181,7 @@ class ManifestEnhancer { this.fsReadDir = promisify(fs.readdir); this.cwd = path.dirname(filePath); this.filePath = filePath; - this.manifest = JSON.parse(manifest); + this.manifest = parseJSON(manifest, filePath); this.isModified = false; this.runInvoked = false; diff --git a/packages/builder/lib/processors/versionInfoGenerator.js b/packages/builder/lib/processors/versionInfoGenerator.js index 3edc80e8427..21ff0d36c88 100644 --- a/packages/builder/lib/processors/versionInfoGenerator.js +++ b/packages/builder/lib/processors/versionInfoGenerator.js @@ -2,6 +2,7 @@ import {getLogger} from "@ui5/logger"; const log = getLogger("builder:processors:versionInfoGenerator"); import {createResource} from "@ui5/fs/resourceFactory"; import posixPath from "node:path/posix"; +import parseJSON from "../utils/json.js"; /** * @public @@ -59,7 +60,7 @@ function getTimestamp() { */ const processManifest = async (manifestResource) => { const manifestContent = await manifestResource.getString(); - const manifestObject = JSON.parse(manifestContent); + const manifestObject = parseJSON(manifestContent, manifestResource.getPath()); const manifestInfo = Object.create(null); // sap.ui5/dependencies is used for the "manifestHints/libs" diff --git a/packages/builder/lib/tasks/bundlers/generateFlexChangesBundle.js b/packages/builder/lib/tasks/bundlers/generateFlexChangesBundle.js index 8c2f8b0feca..99b77bc3a7a 100644 --- a/packages/builder/lib/tasks/bundlers/generateFlexChangesBundle.js +++ b/packages/builder/lib/tasks/bundlers/generateFlexChangesBundle.js @@ -2,6 +2,7 @@ import {getLogger} from "@ui5/logger"; const log = getLogger("builder:tasks:bundlers:generateFlexChangesBundle"); import flexChangesBundler from "../../processors/bundlers/flexChangesBundler.js"; import semver from "semver"; +import parseJSON from "../../utils/json.js"; /** * @public @@ -79,7 +80,7 @@ export default async function({workspace, taskUtil, options = {}}) { log.verbose("No manifest.json found, skipping update of sap.ui.fl dependency and flexBundle flag"); return; } - const manifestContent = JSON.parse(await manifestResource.getString()); + const manifestContent = parseJSON(await manifestResource.getString(), manifestResource.getPath()); updateJson(manifestContent, flexBundleFlag); manifestResource.setString(JSON.stringify(manifestContent, null, "\t")); @@ -93,7 +94,7 @@ export default async function({workspace, taskUtil, options = {}}) { log.verbose("No manifest.json found, cannot read minUI5Version"); return []; } - const manifestContent = JSON.parse(await manifestResource.getString()); + const manifestContent = parseJSON(await manifestResource.getString(), manifestResource.getPath()); manifestContent["sap.ui5"] = manifestContent["sap.ui5"] || {}; manifestContent["sap.ui5"].dependencies = manifestContent["sap.ui5"].dependencies || {}; @@ -123,7 +124,7 @@ export default async function({workspace, taskUtil, options = {}}) { hasFlexBundleVersion = true; const flexBundleResource = await workspace.byPath(`${pathPrefix}/changes/flexibility-bundle.json`); if (flexBundleResource) { - flexBundle = JSON.parse(await flexBundleResource.getString()); + flexBundle = parseJSON(await flexBundleResource.getString(), flexBundleResource.getPath()); } } flexChangesBundleResult = await flexChangesBundler({ diff --git a/packages/builder/lib/utils/json.js b/packages/builder/lib/utils/json.js new file mode 100644 index 00000000000..2a3329f9e67 --- /dev/null +++ b/packages/builder/lib/utils/json.js @@ -0,0 +1,28 @@ +/** + * Parses JSON content, attaching the file path to any parse failure so the + * root cause (empty file vs. syntax error) is always identifiable. + * + * An empty JSON object ({}) is valid and parsed normally; only content-less + * files (empty or whitespace-only) throw ERR_EMPTY_JSON. Any other parse + * failure is re-thrown as ERR_INVALID_JSON with the file path prefixed. + * + * @param {string} content Raw file content + * @param {string} filePath Path of the file the content was read from, used in the error message + * @returns {object} Parsed JSON + * @throws {SyntaxError} with code "ERR_EMPTY_JSON" if content is empty/whitespace-only + * @throws {SyntaxError} with code "ERR_INVALID_JSON" if content is malformed JSON + */ +export default function parseJSON(content, filePath) { + try { + return JSON.parse(content); + } catch (err) { + if (typeof content === "string" && content.trim() === "") { + const error = new SyntaxError(`The file "${filePath}" is empty.`); + error.code = "ERR_EMPTY_JSON"; + throw error; + } + const error = new SyntaxError(`Failed to parse "${filePath}" as JSON: ${err.message}`); + error.code = "ERR_INVALID_JSON"; + throw error; + } +} diff --git a/packages/builder/test/lib/processors/manifestEnhancer.js b/packages/builder/test/lib/processors/manifestEnhancer.js index 2c83f491ada..f7fca42de03 100644 --- a/packages/builder/test/lib/processors/manifestEnhancer.js +++ b/packages/builder/test/lib/processors/manifestEnhancer.js @@ -3043,6 +3043,16 @@ test("ManifestEnhancer#run: multiple parallel executions are not supported", asy }); }); +test("ManifestEnhancer: empty manifest content throws a clear error", (t) => { + const {fs} = t.context; + const {ManifestEnhancer} = t.context.__internals__; + + const error = t.throws(() => new ManifestEnhancer("", "/manifest.json", fs)); + t.is(error.message, `The file "/manifest.json" is empty.`, + "Threw with the clear empty-file error message"); + t.is(error.code, "ERR_EMPTY_JSON", "Threw with the expected error code"); +}); + test("manifestEnhancer#getSupportedLocales", async (t) => { const {fs} = t.context; const {ManifestEnhancer} = t.context.__internals__; diff --git a/packages/builder/test/lib/utils/json.js b/packages/builder/test/lib/utils/json.js new file mode 100644 index 00000000000..46598a295ed --- /dev/null +++ b/packages/builder/test/lib/utils/json.js @@ -0,0 +1,48 @@ +import test from "ava"; +import parseJSON from "../../../lib/utils/json.js"; + +test("parseJSON: Parses valid JSON content", (t) => { + const result = parseJSON(`{"foo": "bar"}`, "/some/file.json"); + t.deepEqual(result, {foo: "bar"}, "Returned the parsed object"); +}); + +test("parseJSON: Parses an empty JSON object", (t) => { + const result = parseJSON("{}", "/some/file.json"); + t.deepEqual(result, {}, "An empty JSON object is valid and parsed correctly"); +}); + +test("parseJSON: Throws for empty content", (t) => { + const error = t.throws(() => parseJSON("", "webapp/manifest.json")); + t.true(error instanceof SyntaxError, "Threw a SyntaxError"); + t.is(error.message, `The file "webapp/manifest.json" is empty.`, + "Threw with the expected error message"); + t.is(error.code, "ERR_EMPTY_JSON", "Threw with the expected error code"); +}); + +test("parseJSON: Throws for whitespace-only content", (t) => { + const error = t.throws(() => parseJSON(" \n\t ", "webapp/manifest.json")); + t.true(error instanceof SyntaxError, "Threw a SyntaxError"); + t.is(error.message, `The file "webapp/manifest.json" is empty.`, + "Threw with the expected error message"); + t.is(error.code, "ERR_EMPTY_JSON", "Threw with the expected error code"); +}); + +test("parseJSON: Throws ERR_INVALID_JSON for malformed JSON", (t) => { + const error = t.throws(() => parseJSON("{bad json", "/some/file.json")); + t.true(error instanceof SyntaxError, "Threw a SyntaxError"); + t.is(error.code, "ERR_INVALID_JSON", "Threw with the expected error code"); + t.true(error.message.startsWith(`Failed to parse "/some/file.json" as JSON:`), + "Error message includes the file path and root cause"); +}); + +test("parseJSON: null content is treated as valid JSON returning null", (t) => { + // JSON.parse(null) succeeds — null is valid JSON — so no error is thrown + const result = parseJSON(null, "/some/file.json"); + t.is(result, null, "JSON.parse(null) returns null without throwing"); +}); + +test("parseJSON: Throws ERR_INVALID_JSON for undefined content", (t) => { + const error = t.throws(() => parseJSON(undefined, "/some/file.json")); + t.is(error.code, "ERR_INVALID_JSON", "Threw with the expected error code"); +}); + diff --git a/packages/project/lib/specifications/types/Application.js b/packages/project/lib/specifications/types/Application.js index f46c8a7fb9a..140e06b81dd 100644 --- a/packages/project/lib/specifications/types/Application.js +++ b/packages/project/lib/specifications/types/Application.js @@ -1,6 +1,7 @@ import fsPath from "node:path"; import ComponentProject from "../ComponentProject.js"; import {createReader} from "@ui5/fs/resourceFactory"; +import parseJSON from "../../utils/json.js"; /** * Application @@ -244,7 +245,7 @@ class Application extends ComponentProject { error.code = "ENOENT"; // "File or directory does not exist" throw error; } - return JSON.parse(await resource.getString()); + return parseJSON(await resource.getString(), `${this._webappPath}${filePath}`); }).catch((err) => { if (err.code === "ENOENT") { throw err; diff --git a/packages/project/lib/specifications/types/Component.js b/packages/project/lib/specifications/types/Component.js index 54a19df7f51..5f0388215b1 100644 --- a/packages/project/lib/specifications/types/Component.js +++ b/packages/project/lib/specifications/types/Component.js @@ -2,6 +2,7 @@ import fsPath from "node:path"; import posixPath from "node:path/posix"; import ComponentProject from "../ComponentProject.js"; import {createReader} from "@ui5/fs/resourceFactory"; +import parseJSON from "../../utils/json.js"; /** * Component @@ -279,11 +280,16 @@ class Component extends ComponentProject { return this._pManifests[filePath] = this._getRawSourceReader().byPath(filePath) .then(async (resource) => { if (!resource) { - throw new Error( + const error = new Error( `Could not find resource ${filePath} in project ${this.getName()}`); + error.code = "ENOENT"; // "File or directory does not exist" + throw error; } - return JSON.parse(await resource.getString()); + return parseJSON(await resource.getString(), `${this._srcPath}${filePath}`); }).catch((err) => { + if (err.code === "ENOENT") { + throw err; + } throw new Error( `Failed to read ${filePath} for project ` + `${this.getName()}: ${err.message}`); diff --git a/packages/project/lib/specifications/types/Library.js b/packages/project/lib/specifications/types/Library.js index 45d1c098a1e..3d0bfddad53 100644 --- a/packages/project/lib/specifications/types/Library.js +++ b/packages/project/lib/specifications/types/Library.js @@ -3,6 +3,7 @@ import posixPath from "node:path/posix"; import {promisify} from "node:util"; import ComponentProject from "../ComponentProject.js"; import * as resourceFactory from "@ui5/fs/resourceFactory"; +import parseJSON from "../../utils/json.js"; /** * Library @@ -498,7 +499,9 @@ class Library extends ComponentProject { return this._pManifest = this._getRawSourceReader().byGlob("**/manifest.json") .then(async (manifestResources) => { if (!manifestResources.length) { - throw new Error(`Could not find manifest.json file for project ${this.getName()}`); + const error = new Error(`Could not find manifest.json file for project ${this.getName()}`); + error.code = "ENOENT"; // "File or directory does not exist" + throw error; } if (manifestResources.length > 1) { throw new Error(`Found multiple (${manifestResources.length}) manifest.json files ` + @@ -507,10 +510,13 @@ class Library extends ComponentProject { const resource = manifestResources[0]; try { return { - content: JSON.parse(await resource.getString()), + content: parseJSON(await resource.getString(), resource.getPath()), filePath: resource.getPath() }; } catch (err) { + if (err.code === "ENOENT") { + throw err; + } throw new Error( `Failed to read ${resource.getPath()} for project ${this.getName()}: ${err.message}`); } diff --git a/packages/project/lib/utils/json.js b/packages/project/lib/utils/json.js new file mode 100644 index 00000000000..2a3329f9e67 --- /dev/null +++ b/packages/project/lib/utils/json.js @@ -0,0 +1,28 @@ +/** + * Parses JSON content, attaching the file path to any parse failure so the + * root cause (empty file vs. syntax error) is always identifiable. + * + * An empty JSON object ({}) is valid and parsed normally; only content-less + * files (empty or whitespace-only) throw ERR_EMPTY_JSON. Any other parse + * failure is re-thrown as ERR_INVALID_JSON with the file path prefixed. + * + * @param {string} content Raw file content + * @param {string} filePath Path of the file the content was read from, used in the error message + * @returns {object} Parsed JSON + * @throws {SyntaxError} with code "ERR_EMPTY_JSON" if content is empty/whitespace-only + * @throws {SyntaxError} with code "ERR_INVALID_JSON" if content is malformed JSON + */ +export default function parseJSON(content, filePath) { + try { + return JSON.parse(content); + } catch (err) { + if (typeof content === "string" && content.trim() === "") { + const error = new SyntaxError(`The file "${filePath}" is empty.`); + error.code = "ERR_EMPTY_JSON"; + throw error; + } + const error = new SyntaxError(`Failed to parse "${filePath}" as JSON: ${err.message}`); + error.code = "ERR_INVALID_JSON"; + throw error; + } +} diff --git a/packages/project/test/lib/specifications/types/Application.js b/packages/project/test/lib/specifications/types/Application.js index cf27337910d..7d72e4694a7 100644 --- a/packages/project/test/lib/specifications/types/Application.js +++ b/packages/project/test/lib/specifications/types/Application.js @@ -575,12 +575,34 @@ test("_getManifest: invalid JSON", async (t) => { }; const error = await t.throwsAsync(project._getManifest("/some-manifest.json")); - t.regex(error.message, /^Failed to read \/some-manifest\.json for project application\.a: /, - "Rejected with correct error message"); + t.true(error.message.startsWith( + `Failed to read /some-manifest.json for project application.a: ` + + `Failed to parse "webapp/some-manifest.json" as JSON`), + "Rejected with the wrapped invalid-JSON error message"); t.is(byPathStub.callCount, 1, "byPath got called once"); t.is(byPathStub.getCall(0).args[0], "/some-manifest.json", "byPath got called with the correct argument"); }); +test("_getManifest: empty file", async (t) => { + const {projectInput, sinon} = t.context; + const project = await Specification.create(projectInput); + + const byPathStub = sinon.stub().resolves({ + getString: async () => "" + }); + + project._getRawSourceReader = () => { + return { + byPath: byPathStub + }; + }; + + const error = await t.throwsAsync(project._getManifest("/some-manifest.json")); + t.is(error.message, + `Failed to read /some-manifest.json for project application.a: The file "webapp/some-manifest.json" is empty.`, + "Rejected with the wrapped empty-file error message"); +}); + test.serial("_getManifest: File does not exist", async (t) => { const {projectInput} = t.context; const project = await Specification.create(projectInput); diff --git a/packages/project/test/lib/specifications/types/Component.js b/packages/project/test/lib/specifications/types/Component.js index 55d78b22bcf..8659e65e40b 100644 --- a/packages/project/test/lib/specifications/types/Component.js +++ b/packages/project/test/lib/specifications/types/Component.js @@ -573,21 +573,43 @@ test("_getManifest: invalid JSON", async (t) => { }; const error = await t.throwsAsync(project._getManifest("/some-manifest.json")); - t.regex(error.message, /^Failed to read \/some-manifest\.json for project component\.a: /, - "Rejected with correct error message"); + t.true(error.message.startsWith( + `Failed to read /some-manifest.json for project component.a: ` + + `Failed to parse "src/some-manifest.json" as JSON`), + "Rejected with the wrapped invalid-JSON error message"); t.is(byPathStub.callCount, 1, "byPath got called once"); t.is(byPathStub.getCall(0).args[0], "/some-manifest.json", "byPath got called with the correct argument"); }); +test("_getManifest: empty file", async (t) => { + const {projectInput, sinon} = t.context; + const project = await Specification.create(projectInput); + + const byPathStub = sinon.stub().resolves({ + getString: async () => "" + }); + + project._getRawSourceReader = () => { + return { + byPath: byPathStub + }; + }; + + const error = await t.throwsAsync(project._getManifest("/some-manifest.json")); + t.is(error.message, + `Failed to read /some-manifest.json for project component.a: The file "src/some-manifest.json" is empty.`, + "Rejected with the wrapped empty-file error message"); +}); + test.serial("_getManifest: File does not exist", async (t) => { const {projectInput} = t.context; const project = await Specification.create(projectInput); const error = await t.throwsAsync(project._getManifest("/does-not-exist.json")); - t.deepEqual(error.message, - "Failed to read /does-not-exist.json for project component.a: " + + t.is(error.message, "Could not find resource /does-not-exist.json in project component.a", "Rejected with correct error message"); + t.is(error.code, "ENOENT"); }); test.serial("_getManifest: result is cached", async (t) => { diff --git a/packages/project/test/lib/specifications/types/Library.js b/packages/project/test/lib/specifications/types/Library.js index 0101e019408..2370e4276ef 100644 --- a/packages/project/test/lib/specifications/types/Library.js +++ b/packages/project/test/lib/specifications/types/Library.js @@ -715,8 +715,32 @@ test("_getManifest: Invalid JSON", async (t) => { }; project._pManifest = null; // Clear cache from instantiation const error = await t.throwsAsync(project._getManifest()); - t.regex(error.message, /^Failed to read some path for project library\.d: /, - "Rejected with correct error message"); + t.true(error.message.startsWith( + `Failed to read some path for project library.d: Failed to parse "some path" as JSON`), + "Rejected with the wrapped invalid-JSON error message"); + t.is(byGlobStub.callCount, 1, "byGlob got called once"); + t.is(byGlobStub.getCall(0).args[0], "**/manifest.json", "byGlob got called with the expected arguments"); +}); + +test("_getManifest: Empty file", async (t) => { + const {projectInput, sinon} = t.context; + + const project = await (new Library().init(projectInput)); + const byGlobStub = sinon.stub().resolves([{ + getString: async () => "", + getPath: () => "some path" + }]); + + project._getRawSourceReader = () => { + return { + byGlob: byGlobStub + }; + }; + project._pManifest = null; // Clear cache from instantiation + const error = await t.throwsAsync(project._getManifest()); + t.is(error.message, + `Failed to read some path for project library.d: The file "some path" is empty.`, + "Rejected with the wrapped empty-file error message"); t.is(byGlobStub.callCount, 1, "byGlob got called once"); t.is(byGlobStub.getCall(0).args[0], "**/manifest.json", "byGlob got called with the expected arguments"); }); diff --git a/packages/project/test/lib/utils/json.js b/packages/project/test/lib/utils/json.js new file mode 100644 index 00000000000..46598a295ed --- /dev/null +++ b/packages/project/test/lib/utils/json.js @@ -0,0 +1,48 @@ +import test from "ava"; +import parseJSON from "../../../lib/utils/json.js"; + +test("parseJSON: Parses valid JSON content", (t) => { + const result = parseJSON(`{"foo": "bar"}`, "/some/file.json"); + t.deepEqual(result, {foo: "bar"}, "Returned the parsed object"); +}); + +test("parseJSON: Parses an empty JSON object", (t) => { + const result = parseJSON("{}", "/some/file.json"); + t.deepEqual(result, {}, "An empty JSON object is valid and parsed correctly"); +}); + +test("parseJSON: Throws for empty content", (t) => { + const error = t.throws(() => parseJSON("", "webapp/manifest.json")); + t.true(error instanceof SyntaxError, "Threw a SyntaxError"); + t.is(error.message, `The file "webapp/manifest.json" is empty.`, + "Threw with the expected error message"); + t.is(error.code, "ERR_EMPTY_JSON", "Threw with the expected error code"); +}); + +test("parseJSON: Throws for whitespace-only content", (t) => { + const error = t.throws(() => parseJSON(" \n\t ", "webapp/manifest.json")); + t.true(error instanceof SyntaxError, "Threw a SyntaxError"); + t.is(error.message, `The file "webapp/manifest.json" is empty.`, + "Threw with the expected error message"); + t.is(error.code, "ERR_EMPTY_JSON", "Threw with the expected error code"); +}); + +test("parseJSON: Throws ERR_INVALID_JSON for malformed JSON", (t) => { + const error = t.throws(() => parseJSON("{bad json", "/some/file.json")); + t.true(error instanceof SyntaxError, "Threw a SyntaxError"); + t.is(error.code, "ERR_INVALID_JSON", "Threw with the expected error code"); + t.true(error.message.startsWith(`Failed to parse "/some/file.json" as JSON:`), + "Error message includes the file path and root cause"); +}); + +test("parseJSON: null content is treated as valid JSON returning null", (t) => { + // JSON.parse(null) succeeds — null is valid JSON — so no error is thrown + const result = parseJSON(null, "/some/file.json"); + t.is(result, null, "JSON.parse(null) returns null without throwing"); +}); + +test("parseJSON: Throws ERR_INVALID_JSON for undefined content", (t) => { + const error = t.throws(() => parseJSON(undefined, "/some/file.json")); + t.is(error.code, "ERR_INVALID_JSON", "Threw with the expected error code"); +}); +