From 6fed17519ae5cb18484bd9e981d5dede67b53ae8 Mon Sep 17 00:00:00 2001 From: Petar Dimov Date: Tue, 21 Oct 2025 14:41:57 +0300 Subject: [PATCH 01/14] feat: Add manifest validation tool --- src/registerTools.ts | 3 + src/tools/run_manifest_validation/index.ts | 34 +++++ .../run_manifest_validation/runValidation.ts | 139 ++++++++++++++++++ src/tools/run_manifest_validation/schema.ts | 61 ++++++++ 4 files changed, 237 insertions(+) create mode 100644 src/tools/run_manifest_validation/index.ts create mode 100644 src/tools/run_manifest_validation/runValidation.ts create mode 100644 src/tools/run_manifest_validation/schema.ts diff --git a/src/registerTools.ts b/src/registerTools.ts index 9f27db0..2e1fc3f 100644 --- a/src/registerTools.ts +++ b/src/registerTools.ts @@ -11,6 +11,7 @@ import registerGetGuidelinesTool from "./tools/get_guidelines/index.js"; import registerGetVersionInfoTool from "./tools/get_version_info/index.js"; import registerGetIntegrationCardsGuidelinesTool from "./tools/get_integration_cards_guidelines/index.js"; import registerCreateIntegrationCardTool from "./tools/create_integration_card/index.js"; +import registerRunManifestValidationTool from "./tools/run_manifest_validation/index.js"; interface Options { useStructuredContentInResponse: boolean; @@ -51,6 +52,8 @@ export default function (server: McpServer, context: Context, options: Options) registerGetIntegrationCardsGuidelinesTool(registerTool, context); registerCreateIntegrationCardTool(registerTool, context); + + registerRunManifestValidationTool(registerTool, context); } export function _processResponse({content, structuredContent}: CallToolResult, options: Options) { diff --git a/src/tools/run_manifest_validation/index.ts b/src/tools/run_manifest_validation/index.ts new file mode 100644 index 0000000..5914c91 --- /dev/null +++ b/src/tools/run_manifest_validation/index.ts @@ -0,0 +1,34 @@ +import runValidation from "./runValidation.js"; +import {inputSchema, outputSchema} from "./schema.js"; +import {getLogger} from "@ui5/logger"; +import Context from "../../Context.js"; +import {RegisterTool} from "../../registerTools.js"; + +const log = getLogger("tools:run_manifest_validation"); + +export default function registerTool(registerTool: RegisterTool, _context: Context) { + registerTool("run_manifest_validation", { + description: + "Validates UI5 manifest file." + + "After making changes, you should always run the validation again " + + "to verify that no new problems have been introduced.", + annotations: { + title: "Manifest Validation", + readOnlyHint: false, + }, + inputSchema, + outputSchema, + }, async ({manifestPath}) => { + log.info(`Running manifest validation on ${manifestPath}...`); + + const result = await runValidation(manifestPath); + + return { + content: [{ + type: "text", + text: JSON.stringify(result), + }], + structuredContent: result, + }; + }); +} diff --git a/src/tools/run_manifest_validation/runValidation.ts b/src/tools/run_manifest_validation/runValidation.ts new file mode 100644 index 0000000..ff19ac7 --- /dev/null +++ b/src/tools/run_manifest_validation/runValidation.ts @@ -0,0 +1,139 @@ +import {fetchCdn} from "../../utils/cdnHelper.js"; +import {RunSchemaValidationResult} from "./schema.js"; +import Ajv2020, {AnySchemaObject} from "ajv/dist/2020.js"; +import {readFile} from "fs/promises"; +import {getLogger} from "@ui5/logger"; +import {InvalidInputError} from "../../utils.js"; + +const log = getLogger("tools:run_manifest_validation:runValidation"); +const schemaCache = new Map>(); + +async function createUI5ManifestValidateFunction() { + const ajv = new Ajv2020.default({ + allErrors: true, // Collect all errors, not just the first one + strict: false, // Allow additional properties that are not in schema + unicodeRegExp: false, + loadSchema: async (uri) => { + // Check cache first to prevent infinite loops + if (schemaCache.has(uri)) { + log.info(`Loading cached schema: ${uri}`); + + try { + const schema = await schemaCache.get(uri)!; + return schema; + } catch { + schemaCache.delete(uri); + } + } + + log.info(`Loading external schema: ${uri}`); + let fetchSchema: Promise; + + try { + if (uri.includes("adaptive-card.json")) { + // Special handling for Adaptive Card schema to fix unsupported "id" property + // According to the JSON Schema spec Draft 06 (used by Adaptive Card schema), + // "$id" should be used instead of "id" + fetchSchema = fetchCdn(uri) + .then((response) => { + if ("id" in response && typeof response.id === "string") { + const typedResponse = response as Record; + typedResponse.$id = response.id; + delete typedResponse.id; + } + return response; + }); + } else { + fetchSchema = fetchCdn(uri); + } + + schemaCache.set(uri, fetchSchema); + return fetchSchema; + } catch (error) { + log.warn(`Failed to load external schema ${uri}:` + + `${error instanceof Error ? error.message : String(error)}`); + + throw error; + } + }, + }); + const draft06MetaSchema = JSON.parse( + await readFile("node_modules/ajv/dist/refs/json-schema-draft-06.json", "utf-8") + ) as AnySchemaObject; + const draft07MetaSchema = JSON.parse( + await readFile("node_modules/ajv/dist/refs/json-schema-draft-07.json", "utf-8") + ) as AnySchemaObject; + + ajv.addMetaSchema(draft06MetaSchema, "http://json-schema.org/draft-06/schema#"); + ajv.addMetaSchema(draft07MetaSchema, "http://json-schema.org/draft-07/schema#"); + + // Fetch the UI5 manifest schema + const schemaUrl = "https://raw.githubusercontent.com/SAP/ui5-manifest/master/schema.json"; + const schema = await fetchCdn(schemaUrl); + log.info(`Fetched UI5 manifest schema from ${schemaUrl}`); + + const validate = await ajv.compileAsync(schema); + + return validate; +} + +async function readManifest(path: string) { + let content: string; + let json: object; + + try { + content = await readFile(path, "utf-8"); + } catch (error) { + throw new InvalidInputError(`Failed to read manifest file at ${path}: ` + + `${error instanceof Error ? error.message : String(error)}`); + } + + try { + json = JSON.parse(content) as object; + } catch (error) { + throw new InvalidInputError(`Failed to parse manifest file at ${path} as JSON: ` + + `${error instanceof Error ? error.message : String(error)}`); + } + + return json; +} + +export default async function runValidation(manifestPath: string): Promise { + log.info(`Starting manifest validation for file: ${manifestPath}`); + + const manifest = await readManifest(manifestPath); + const validate = await createUI5ManifestValidateFunction(); + const isValid = validate(manifest); + + if (isValid) { + log.info("Manifest validation successful"); + + return { + isValid: true, + errors: [], + }; + } + + // Map AJV errors to our schema format + const validationErrors = validate.errors ?? []; + const errors = validationErrors.map((error) => { + return { + keyword: error.keyword ?? "", + instancePath: error.instancePath ?? "", + schemaPath: error.schemaPath ?? "", + params: error.params ?? {}, + propertyName: error.propertyName, + message: error.message, + schema: error.schema, + parentSchema: error.parentSchema, + data: error.data, + }; + }); + + log.info(`Manifest validation failed with ${errors.length} error(s)`); + + return { + isValid: false, + errors: errors, + }; +} diff --git a/src/tools/run_manifest_validation/schema.ts b/src/tools/run_manifest_validation/schema.ts new file mode 100644 index 0000000..1601e3c --- /dev/null +++ b/src/tools/run_manifest_validation/schema.ts @@ -0,0 +1,61 @@ +import {z} from "zod"; + +export const inputSchema = { + manifestPath: z.string() + .describe("Path to the manifest file to validate."), +}; + +export const outputSchema = { + isValid: z.boolean() + .describe("Whether the manifest is valid according to the UI5 Manifest schema."), + errors: z.array( + z.object({ + keyword: z.string() + .describe("Validation keyword."), + instancePath: z.string() + .describe("JSON Pointer to the location in the data instance (e.g., `/prop/1/subProp`)."), + schemaPath: z.string() + .describe("JSON Pointer to the location of the failing keyword in the schema."), + params: z.record(z.any()) + .describe("An object with additional information about the error."), + propertyName: z.string() + .optional() + .describe("Set for errors in `propertyNames` keyword schema."), + message: z.string() + .optional() + .describe("The error message."), + schema: z.any() + .optional() + .describe("The value of the failing keyword in the schema."), + parentSchema: z.record(z.any()) + .optional() + .describe("The schema containing the keyword."), + data: z.any() + .optional() + .describe("The data validated by the keyword."), + }) + ).describe("Array of validation error objects as returned by Ajv."), + + // errors: z.array( + // z.object({ + // path: z.array( + // z.any() + // ).describe("An array of property keys or array offsets," + + // "indicating where inside objects or arrays the instance was found"), + // property: z.string() + // .describe("Describes the property path. Starts with instance, and is delimited with a dot (.)"), + // message: z.string() + // .describe("A human-readable message for debugging use."), + // instance: z.any() + // .describe("The instance that failed"), + // name: z.string() + // .describe("The keyword within the schema that failed."), + // argument: z.any() + // .describe("Provides information about the keyword that failed."), + // stack: z.string() + // .describe("A human-readable string representing the error."), + // }).describe("Single schema error object.") + // ), +}; +export const outputSchemaObject = z.object(outputSchema); +export type RunSchemaValidationResult = z.infer; From 278273f0c0b7dd7a8f738b6f2ac97605e5b30a82 Mon Sep 17 00:00:00 2001 From: Petar Dimov Date: Tue, 21 Oct 2025 16:56:08 +0300 Subject: [PATCH 02/14] refactor: Move manifest schema fetching to ui5Manifest.ts --- .../run_manifest_validation/runValidation.ts | 15 +++++++-------- src/utils/ui5Manifest.ts | 15 +++++++++++++++ 2 files changed, 22 insertions(+), 8 deletions(-) diff --git a/src/tools/run_manifest_validation/runValidation.ts b/src/tools/run_manifest_validation/runValidation.ts index ff19ac7..b0355fb 100644 --- a/src/tools/run_manifest_validation/runValidation.ts +++ b/src/tools/run_manifest_validation/runValidation.ts @@ -4,11 +4,12 @@ import Ajv2020, {AnySchemaObject} from "ajv/dist/2020.js"; import {readFile} from "fs/promises"; import {getLogger} from "@ui5/logger"; import {InvalidInputError} from "../../utils.js"; +import {getManifestSchema} from "../../utils/ui5Manifest.js"; const log = getLogger("tools:run_manifest_validation:runValidation"); const schemaCache = new Map>(); -async function createUI5ManifestValidateFunction() { +async function createUI5ManifestValidateFunction(ui5Schema: object) { const ajv = new Ajv2020.default({ allErrors: true, // Collect all errors, not just the first one strict: false, // Allow additional properties that are not in schema @@ -67,12 +68,7 @@ async function createUI5ManifestValidateFunction() { ajv.addMetaSchema(draft06MetaSchema, "http://json-schema.org/draft-06/schema#"); ajv.addMetaSchema(draft07MetaSchema, "http://json-schema.org/draft-07/schema#"); - // Fetch the UI5 manifest schema - const schemaUrl = "https://raw.githubusercontent.com/SAP/ui5-manifest/master/schema.json"; - const schema = await fetchCdn(schemaUrl); - log.info(`Fetched UI5 manifest schema from ${schemaUrl}`); - - const validate = await ajv.compileAsync(schema); + const validate = await ajv.compileAsync(ui5Schema); return validate; } @@ -102,7 +98,10 @@ export default async function runValidation(manifestPath: string): Promise Date: Wed, 22 Oct 2025 15:30:28 +0300 Subject: [PATCH 03/14] test(index.ts): Add tests --- src/tools/run_manifest_validation/index.ts | 1 + .../tools/run_manifest_validation/index.ts | 147 ++++++++++++++++++ 2 files changed, 148 insertions(+) create mode 100644 test/lib/tools/run_manifest_validation/index.ts diff --git a/src/tools/run_manifest_validation/index.ts b/src/tools/run_manifest_validation/index.ts index 5914c91..6ae8284 100644 --- a/src/tools/run_manifest_validation/index.ts +++ b/src/tools/run_manifest_validation/index.ts @@ -8,6 +8,7 @@ const log = getLogger("tools:run_manifest_validation"); export default function registerTool(registerTool: RegisterTool, _context: Context) { registerTool("run_manifest_validation", { + title: "Manifest Validation", description: "Validates UI5 manifest file." + "After making changes, you should always run the validation again " + diff --git a/test/lib/tools/run_manifest_validation/index.ts b/test/lib/tools/run_manifest_validation/index.ts new file mode 100644 index 0000000..e29af86 --- /dev/null +++ b/test/lib/tools/run_manifest_validation/index.ts @@ -0,0 +1,147 @@ +import anyTest, {TestFn} from "ava"; +import esmock from "esmock"; +import sinonGlobal from "sinon"; +import TestContext from "../../../utils/TestContext.js"; + +// Define test context type +const test = anyTest as TestFn<{ + sinon: sinonGlobal.SinonSandbox; + registerToolCallback: sinonGlobal.SinonStub; + loggerMock: { + silly: sinonGlobal.SinonStub; + verbose: sinonGlobal.SinonStub; + perf: sinonGlobal.SinonStub; + info: sinonGlobal.SinonStub; + warn: sinonGlobal.SinonStub; + error: sinonGlobal.SinonStub; + isLevelEnabled: sinonGlobal.SinonStub; + }; + runValidationStub: sinonGlobal.SinonStub; + registerRunManifestValidationTool: typeof import( + "../../../../src/tools/run_manifest_validation/index.js" + ).default; +}>; + +// Setup test context before each test +test.beforeEach(async (t) => { + // Create a sandbox for sinon stubs + t.context.sinon = sinonGlobal.createSandbox(); + + t.context.registerToolCallback = t.context.sinon.stub(); + + // Create logger mock + const loggerMock = { + silly: t.context.sinon.stub(), + verbose: t.context.sinon.stub(), + perf: t.context.sinon.stub(), + info: t.context.sinon.stub(), + warn: t.context.sinon.stub(), + error: t.context.sinon.stub(), + isLevelEnabled: t.context.sinon.stub().returns(true), + }; + t.context.loggerMock = loggerMock; + + const runValidationStub = t.context.sinon.stub(); + t.context.runValidationStub = runValidationStub; + + // Import the tool registration function with mocked dependencies + const {default: registerRunManifestValidationTool} = await esmock( + "../../../../src/tools/run_manifest_validation/index.js", { + "../../../../src/tools/run_manifest_validation/runValidation.js": { + default: runValidationStub, + }, + } + ); + + t.context.registerRunManifestValidationTool = registerRunManifestValidationTool; +}); + +// Clean up after each test +test.afterEach.always((t) => { + t.context.sinon.restore(); +}); + +test("registerRunManifestValidationTool registers the tool with correct parameters", (t) => { + const {registerToolCallback, registerRunManifestValidationTool} = t.context; + + registerRunManifestValidationTool(registerToolCallback, new TestContext()); + + t.true(registerToolCallback.calledOnce); + t.is(registerToolCallback.firstCall.args[0], "run_manifest_validation"); + + // Verify tool configuration + const toolConfig = registerToolCallback.firstCall.args[1]; + t.true(toolConfig?.title?.includes("Manifest Validation")); + t.true(toolConfig?.description?.includes("Validates UI5 manifest file")); + t.is(toolConfig?.annotations?.title, "Manifest Validation"); + t.false(toolConfig?.annotations?.readOnlyHint); +}); + +test("run_manifest_validation tool returns validation result on success", async (t) => { + const { + registerToolCallback, + registerRunManifestValidationTool, + runValidationStub, + } = t.context; + + // Setup runValidation to return a sample result + const sampleResult = { + valid: true, + issues: [], + }; + runValidationStub.resolves(sampleResult); + + // Register the tool and capture the execute function + registerRunManifestValidationTool(registerToolCallback, new TestContext()); + const executeFunction = registerToolCallback.firstCall.args[2]; + + const mockExtra = { + signal: new AbortController().signal, + requestId: "test-request-id", + sendNotification: t.context.sinon.stub(), + sendRequest: t.context.sinon.stub(), + }; + + // Execute the tool + const manifestPath = "/path/to/valid/manifest.json"; + const result = await executeFunction({manifestPath}, mockExtra); + + t.deepEqual(result, { + content: [{ + type: "text", + text: JSON.stringify(sampleResult), + }], + structuredContent: sampleResult, + }); +}); + +test("run_manifest_validation tool handles errors correctly", async (t) => { + const { + registerToolCallback, + registerRunManifestValidationTool, + runValidationStub, + } = t.context; + + // Setup readFile to throw an error + const errorMessage = "Failed to read manifest file"; + runValidationStub.rejects(new Error(errorMessage)); + + // Register the tool and capture the execute function + registerRunManifestValidationTool(registerToolCallback, new TestContext()); + const executeFunction = registerToolCallback.firstCall.args[2]; + + const mockExtra = { + signal: new AbortController().signal, + requestId: "test-request-id", + sendNotification: t.context.sinon.stub(), + sendRequest: t.context.sinon.stub(), + }; + + // Execute the tool + const manifestPath = "/path/to/invalid/manifest.json"; + await t.throwsAsync(async () => { + await executeFunction({manifestPath}, mockExtra); + }, { + message: errorMessage, + }); +}); From 00de71103602aaa05e91ed912fffb481b34d73be Mon Sep 17 00:00:00 2001 From: Petar Dimov Date: Wed, 22 Oct 2025 15:31:05 +0300 Subject: [PATCH 04/14] refactor(ui5Manifest.ts): Cache the schema --- src/utils/ui5Manifest.ts | 29 +++++++++++++++++++++++------ 1 file changed, 23 insertions(+), 6 deletions(-) diff --git a/src/utils/ui5Manifest.ts b/src/utils/ui5Manifest.ts index 217f228..e17cde2 100644 --- a/src/utils/ui5Manifest.ts +++ b/src/utils/ui5Manifest.ts @@ -1,9 +1,10 @@ import {getLogger} from "@ui5/logger"; import {fetchCdn} from "./cdnHelper.js"; -const log = getLogger("utils:dataStorageHelper"); +const log = getLogger("utils:ui5Manifest"); const MAPPING_URL = "https://raw.githubusercontent.com/SAP/ui5-manifest/main/mapping.json"; const LATEST_SCHEMA_URL = "https://raw.githubusercontent.com/SAP/ui5-manifest/main/schema.json"; +const schemaCache = new Map>(); async function getUI5toManifestVersionMap() { const mapping = await fetchCdn(MAPPING_URL); @@ -11,6 +12,26 @@ async function getUI5toManifestVersionMap() { return mapping as Record; } +async function fetchSchema(manifestVersion: string) { + if (schemaCache.has(manifestVersion)) { + log.info(`Loading cached schema for manifest version: ${manifestVersion}`); + + try { + const schema = await schemaCache.get(manifestVersion)!; + return schema; + } catch { + schemaCache.delete(manifestVersion); + } + } + + log.info(`Fetching schema for manifest version: ${manifestVersion}`); + schemaCache.set(manifestVersion, fetchCdn(LATEST_SCHEMA_URL)); + const schema = await schemaCache.get(manifestVersion)!; + log.info(`Fetched UI5 manifest schema from ${LATEST_SCHEMA_URL}`); + + return schema; +} + export async function getLatestManifestVersion() { const versionMap = await getUI5toManifestVersionMap(); @@ -26,9 +47,5 @@ export async function getManifestSchema(manifestVersion: string) { throw new Error(`Only 'latest' manifest version is supported, but got '${manifestVersion}'.`); } - // Fetch the UI5 manifest schema - const schema = await fetchCdn(LATEST_SCHEMA_URL); - log.info(`Fetched UI5 manifest schema from ${LATEST_SCHEMA_URL}`); - - return schema; + return await fetchSchema(manifestVersion); } From d6970ea57433a1e546381cf8d2aecd58e711559b Mon Sep 17 00:00:00 2001 From: Petar Dimov Date: Mon, 27 Oct 2025 15:07:27 +0200 Subject: [PATCH 05/14] docs(README.md): List run_manifest_validation --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index af39d6e..96fea66 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,7 @@ The UI5 [Model Context Protocol](https://modelcontextprotocol.io/) server offers - `run_ui5_linter`: Integrates with [`@ui5/linter`](https://github.com/UI5/linter) to analyze and report issues in UI5 code. - `get_integration_cards_guidelines`: Provides access to UI Integration Cards development best practices. - `create_integration_card`: Scaffolds a new UI Integration Card. +- `run_manifest_validation`: Validates the manifest against the UI5 Manifest schema. ## Requirements From a5ddede819a7e6e4af995fd392e51edca09cecf0 Mon Sep 17 00:00:00 2001 From: Petar Dimov Date: Mon, 27 Oct 2025 15:41:38 +0200 Subject: [PATCH 06/14] refactor(ui5Manifest): Add cache --- src/utils/ui5Manifest.ts | 62 ++++++++++++++++++--------- test/lib/utils/ui5Manifest.ts | 81 ++++++++++++++++++++++++++++++++++- 2 files changed, 122 insertions(+), 21 deletions(-) diff --git a/src/utils/ui5Manifest.ts b/src/utils/ui5Manifest.ts index e17cde2..3f2bb7e 100644 --- a/src/utils/ui5Manifest.ts +++ b/src/utils/ui5Manifest.ts @@ -1,39 +1,61 @@ import {getLogger} from "@ui5/logger"; import {fetchCdn} from "./cdnHelper.js"; +import {Mutex} from "async-mutex"; const log = getLogger("utils:ui5Manifest"); -const MAPPING_URL = "https://raw.githubusercontent.com/SAP/ui5-manifest/main/mapping.json"; + const LATEST_SCHEMA_URL = "https://raw.githubusercontent.com/SAP/ui5-manifest/main/schema.json"; -const schemaCache = new Map>(); +const schemaCache = new Map(); +const fetchSchemaMutex = new Mutex(); + +let UI5ToManifestVersionMapping: Record | null = null; +const MAPPING_URL = "https://raw.githubusercontent.com/SAP/ui5-manifest/main/mapping.json"; +const ui5ToManifestVersionMappingMutex = new Mutex(); + +async function getUI5toManifestVersionMapping() { + const release = await ui5ToManifestVersionMappingMutex.acquire(); + + try { + if (UI5ToManifestVersionMapping) { + log.info("Loading cached UI5 to manifest version mapping"); + return UI5ToManifestVersionMapping; + } -async function getUI5toManifestVersionMap() { - const mapping = await fetchCdn(MAPPING_URL); + log.info("Fetching UI5 to manifest version mapping"); + const mapping = await fetchCdn(MAPPING_URL); + log.info(`Fetched UI5 to manifest version mapping from ${MAPPING_URL}`); - return mapping as Record; + UI5ToManifestVersionMapping = mapping as Record; + + return UI5ToManifestVersionMapping; + } finally { + release(); + } } async function fetchSchema(manifestVersion: string) { - if (schemaCache.has(manifestVersion)) { - log.info(`Loading cached schema for manifest version: ${manifestVersion}`); - - try { - const schema = await schemaCache.get(manifestVersion)!; - return schema; - } catch { - schemaCache.delete(manifestVersion); + const release = await fetchSchemaMutex.acquire(); + + try { + if (schemaCache.has(manifestVersion)) { + log.info(`Loading cached schema for manifest version: ${manifestVersion}`); + return schemaCache.get(manifestVersion)!; } - } - log.info(`Fetching schema for manifest version: ${manifestVersion}`); - schemaCache.set(manifestVersion, fetchCdn(LATEST_SCHEMA_URL)); - const schema = await schemaCache.get(manifestVersion)!; - log.info(`Fetched UI5 manifest schema from ${LATEST_SCHEMA_URL}`); + log.info(`Fetching schema for manifest version: ${manifestVersion}`); + const schema = await fetchCdn(LATEST_SCHEMA_URL); + log.info(`Fetched UI5 manifest schema from ${LATEST_SCHEMA_URL}`); + + schemaCache.set(manifestVersion, schema); - return schema; + return schema; + } finally { + release(); + } } export async function getLatestManifestVersion() { - const versionMap = await getUI5toManifestVersionMap(); + const versionMap = await getUI5toManifestVersionMapping(); if (!versionMap.latest) { throw new Error("Could not determine latest manifest version."); diff --git a/test/lib/utils/ui5Manifest.ts b/test/lib/utils/ui5Manifest.ts index fece738..e3905af 100644 --- a/test/lib/utils/ui5Manifest.ts +++ b/test/lib/utils/ui5Manifest.ts @@ -6,6 +6,7 @@ const test = anyTest as TestFn<{ sinon: sinonGlobal.SinonSandbox; fetchCdnStub: sinonGlobal.SinonStub; getLatestManifestVersion: typeof import("../../../src/utils/ui5Manifest.js").getLatestManifestVersion; + getManifestSchema: typeof import("../../../src/utils/ui5Manifest.js").getManifestSchema; }>; test.beforeEach(async (t) => { @@ -15,13 +16,14 @@ test.beforeEach(async (t) => { t.context.fetchCdnStub = fetchCdnStub; // Import the module with mocked dependencies - const {getLatestManifestVersion} = await esmock("../../../src/utils/ui5Manifest.js", { + const {getLatestManifestVersion, getManifestSchema} = await esmock("../../../src/utils/ui5Manifest.js", { "../../../src/utils/cdnHelper.js": { fetchCdn: fetchCdnStub, }, }); t.context.getLatestManifestVersion = getLatestManifestVersion; + t.context.getManifestSchema = getManifestSchema; }); test.afterEach.always((t) => { @@ -43,6 +45,23 @@ test("getLatestManifestVersion returns correct version from CDN data", async (t) t.true(fetchCdnStub.calledOnce); }); +test("getLatestManifestVersion uses cache on subsequent calls", async (t) => { + const {fetchCdnStub, getLatestManifestVersion} = t.context; + const mockData = { + "latest": "1.79.0", + "1.141": "1.79.0", + "1.140": "1.78.0", + }; + fetchCdnStub.resolves(mockData); + + const latestVersion1 = await getLatestManifestVersion(); + const latestVersion2 = await getLatestManifestVersion(); + + t.is(latestVersion1, "1.79.0"); + t.is(latestVersion2, "1.79.0"); + t.true(fetchCdnStub.calledOnce); +}); + test("getLatestManifestVersion handles fetch errors", async (t) => { const {fetchCdnStub, getLatestManifestVersion} = t.context; @@ -78,3 +97,63 @@ test("getLatestManifestVersion handles missing latest version", async (t) => { ); t.true(fetchCdnStub.calledOnce); }); + +test("getManifestSchema throws error for unsupported versions", async (t) => { + const {getManifestSchema} = t.context; + + await t.throwsAsync( + async () => { + await getManifestSchema("1.78.0"); + }, + { + message: "Only 'latest' manifest version is supported, but got '1.78.0'.", + } + ); +}); + +test("getManifestSchema fetches schema for 'latest' version", async (t) => { + const {fetchCdnStub, getManifestSchema} = t.context; + const mockSchema = { + $schema: "http://json-schema.org/draft-07/schema#", + type: "object", + }; + fetchCdnStub.resolves(mockSchema); + + const schema = await getManifestSchema("latest"); + + t.deepEqual(schema, mockSchema); + t.true(fetchCdnStub.calledOnce); +}); + +test("getManifestSchema uses cache on subsequent calls", async (t) => { + const {fetchCdnStub, getManifestSchema} = t.context; + const mockSchema = { + $schema: "http://json-schema.org/draft-07/schema#", + type: "object", + }; + fetchCdnStub.resolves(mockSchema); + + const schema1 = await getManifestSchema("latest"); + const schema2 = await getManifestSchema("latest"); + + t.deepEqual(schema1, mockSchema); + t.deepEqual(schema2, mockSchema); + t.true(fetchCdnStub.calledOnce); +}); + +test("getManifestSchema handles fetch errors", async (t) => { + const {fetchCdnStub, getManifestSchema} = t.context; + + // Mock fetch error + fetchCdnStub.rejects(new Error("Network error")); + + await t.throwsAsync( + async () => { + await getManifestSchema("latest"); + }, + { + message: "Network error", + } + ); + t.true(fetchCdnStub.calledOnce); +}); From f08c45d63151435ecfe1f0234520fdd61c7280ac Mon Sep 17 00:00:00 2001 From: Petar Dimov Date: Mon, 27 Oct 2025 15:56:09 +0200 Subject: [PATCH 07/14] refactor: Improve error handling --- .../run_manifest_validation/runValidation.ts | 121 ++++++++++-------- src/tools/run_manifest_validation/schema.ts | 25 +--- 2 files changed, 68 insertions(+), 78 deletions(-) diff --git a/src/tools/run_manifest_validation/runValidation.ts b/src/tools/run_manifest_validation/runValidation.ts index b0355fb..dadd914 100644 --- a/src/tools/run_manifest_validation/runValidation.ts +++ b/src/tools/run_manifest_validation/runValidation.ts @@ -9,68 +9,79 @@ import {getManifestSchema} from "../../utils/ui5Manifest.js"; const log = getLogger("tools:run_manifest_validation:runValidation"); const schemaCache = new Map>(); -async function createUI5ManifestValidateFunction(ui5Schema: object) { - const ajv = new Ajv2020.default({ - allErrors: true, // Collect all errors, not just the first one - strict: false, // Allow additional properties that are not in schema - unicodeRegExp: false, - loadSchema: async (uri) => { - // Check cache first to prevent infinite loops - if (schemaCache.has(uri)) { - log.info(`Loading cached schema: ${uri}`); +// Configuration constants +const AJV_SCHEMA_PATHS = { + draft06: "node_modules/ajv/dist/refs/json-schema-draft-06.json", + draft07: "node_modules/ajv/dist/refs/json-schema-draft-07.json", +} as const; - try { - const schema = await schemaCache.get(uri)!; - return schema; - } catch { - schemaCache.delete(uri); - } - } - - log.info(`Loading external schema: ${uri}`); - let fetchSchema: Promise; - - try { - if (uri.includes("adaptive-card.json")) { - // Special handling for Adaptive Card schema to fix unsupported "id" property - // According to the JSON Schema spec Draft 06 (used by Adaptive Card schema), - // "$id" should be used instead of "id" - fetchSchema = fetchCdn(uri) - .then((response) => { - if ("id" in response && typeof response.id === "string") { - const typedResponse = response as Record; - typedResponse.$id = response.id; - delete typedResponse.id; - } - return response; - }); - } else { - fetchSchema = fetchCdn(uri); +async function createUI5ManifestValidateFunction(ui5Schema: object) { + try { + const ajv = new Ajv2020.default({ + allErrors: true, // Collect all errors, not just the first one + strict: false, // Allow additional properties that are not in schema + unicodeRegExp: false, + loadSchema: async (uri) => { + // Check cache first to prevent infinite loops + if (schemaCache.has(uri)) { + log.info(`Loading cached schema: ${uri}`); + + try { + const schema = await schemaCache.get(uri)!; + return schema; + } catch { + schemaCache.delete(uri); + } } - schemaCache.set(uri, fetchSchema); - return fetchSchema; - } catch (error) { - log.warn(`Failed to load external schema ${uri}:` + - `${error instanceof Error ? error.message : String(error)}`); + log.info(`Loading external schema: ${uri}`); + let fetchSchema: Promise; - throw error; - } - }, - }); - const draft06MetaSchema = JSON.parse( - await readFile("node_modules/ajv/dist/refs/json-schema-draft-06.json", "utf-8") - ) as AnySchemaObject; - const draft07MetaSchema = JSON.parse( - await readFile("node_modules/ajv/dist/refs/json-schema-draft-07.json", "utf-8") - ) as AnySchemaObject; + try { + if (uri.includes("adaptive-card.json")) { + // Special handling for Adaptive Card schema to fix unsupported "id" property + // According to the JSON Schema spec Draft 06 (used by Adaptive Card schema), + // "$id" should be used instead of "id" + fetchSchema = fetchCdn(uri) + .then((response) => { + if ("id" in response && typeof response.id === "string") { + const typedResponse = response as Record; + typedResponse.$id = response.id; + delete typedResponse.id; + } + return response; + }); + } else { + fetchSchema = fetchCdn(uri); + } + + schemaCache.set(uri, fetchSchema); + return fetchSchema; + } catch (error) { + log.warn(`Failed to load external schema ${uri}:` + + `${error instanceof Error ? error.message : String(error)}`); + + throw error; + } + }, + }); + const draft06MetaSchema = JSON.parse( + await readFile(AJV_SCHEMA_PATHS.draft06, "utf-8") + ) as AnySchemaObject; + const draft07MetaSchema = JSON.parse( + await readFile(AJV_SCHEMA_PATHS.draft07, "utf-8") + ) as AnySchemaObject; - ajv.addMetaSchema(draft06MetaSchema, "http://json-schema.org/draft-06/schema#"); - ajv.addMetaSchema(draft07MetaSchema, "http://json-schema.org/draft-07/schema#"); + ajv.addMetaSchema(draft06MetaSchema, "http://json-schema.org/draft-06/schema#"); + ajv.addMetaSchema(draft07MetaSchema, "http://json-schema.org/draft-07/schema#"); - const validate = await ajv.compileAsync(ui5Schema); + const validate = await ajv.compileAsync(ui5Schema); - return validate; + return validate; + } catch (error) { + throw new Error(`Failed to create UI5 manifest validate function: ` + + `${error instanceof Error ? error.message : String(error)}`); + } } async function readManifest(path: string) { diff --git a/src/tools/run_manifest_validation/schema.ts b/src/tools/run_manifest_validation/schema.ts index 1601e3c..5220ebb 100644 --- a/src/tools/run_manifest_validation/schema.ts +++ b/src/tools/run_manifest_validation/schema.ts @@ -35,27 +35,6 @@ export const outputSchema = { .describe("The data validated by the keyword."), }) ).describe("Array of validation error objects as returned by Ajv."), - - // errors: z.array( - // z.object({ - // path: z.array( - // z.any() - // ).describe("An array of property keys or array offsets," + - // "indicating where inside objects or arrays the instance was found"), - // property: z.string() - // .describe("Describes the property path. Starts with instance, and is delimited with a dot (.)"), - // message: z.string() - // .describe("A human-readable message for debugging use."), - // instance: z.any() - // .describe("The instance that failed"), - // name: z.string() - // .describe("The keyword within the schema that failed."), - // argument: z.any() - // .describe("Provides information about the keyword that failed."), - // stack: z.string() - // .describe("A human-readable string representing the error."), - // }).describe("Single schema error object.") - // ), }; -export const outputSchemaObject = z.object(outputSchema); -export type RunSchemaValidationResult = z.infer; +const _outputSchemaObject = z.object(outputSchema); +export type RunSchemaValidationResult = z.infer; From a74700eb60affad96b2880d79f430bed1aeb9728 Mon Sep 17 00:00:00 2001 From: Petar Dimov Date: Mon, 27 Oct 2025 16:32:29 +0200 Subject: [PATCH 08/14] test(runValidation): Add tests --- resources/integration_cards_guidelines.md | 1 + .../run_manifest_validation/runValidation.ts | 50 ++-- test/fixtures/manifest_validation/schema.json | 46 ++++ .../manifest_validation/valid-manifest.json | 26 ++ .../runValidation.integration.ts | 45 +++ .../run_manifest_validation/runValidation.ts | 256 ++++++++++++++++++ 6 files changed, 394 insertions(+), 30 deletions(-) create mode 100644 test/fixtures/manifest_validation/schema.json create mode 100644 test/fixtures/manifest_validation/valid-manifest.json create mode 100644 test/lib/tools/run_manifest_validation/runValidation.integration.ts create mode 100644 test/lib/tools/run_manifest_validation/runValidation.ts diff --git a/resources/integration_cards_guidelines.md b/resources/integration_cards_guidelines.md index e480aae..6372d66 100644 --- a/resources/integration_cards_guidelines.md +++ b/resources/integration_cards_guidelines.md @@ -42,6 +42,7 @@ ## 2. Validation - **ALWAYS** ensure that `manifest.json` file is valid JSON. - **ALWAYS** ensure that in `manifest.json` file the property `sap.app/type` is set to `"card"`. +- **ALWAYS** validate the `manifest.json` against the UI5 Manifest schema. You must do it using the `run_manifest_validation` tool. - **ALWAYS** avoid using deprecated properties in `manifest.json` and elsewhere. - **NEVER** treat Integration Cards' project as UI5 project, except for cards of type "Component". diff --git a/src/tools/run_manifest_validation/runValidation.ts b/src/tools/run_manifest_validation/runValidation.ts index dadd914..e374918 100644 --- a/src/tools/run_manifest_validation/runValidation.ts +++ b/src/tools/run_manifest_validation/runValidation.ts @@ -5,11 +5,12 @@ import {readFile} from "fs/promises"; import {getLogger} from "@ui5/logger"; import {InvalidInputError} from "../../utils.js"; import {getManifestSchema} from "../../utils/ui5Manifest.js"; +import {Mutex} from "async-mutex"; const log = getLogger("tools:run_manifest_validation:runValidation"); -const schemaCache = new Map>(); +const schemaCache = new Map(); +const fetchSchemaMutex = new Mutex(); -// Configuration constants const AJV_SCHEMA_PATHS = { draft06: "node_modules/ajv/dist/refs/json-schema-draft-06.json", draft07: "node_modules/ajv/dist/refs/json-schema-draft-07.json", @@ -22,46 +23,35 @@ async function createUI5ManifestValidateFunction(ui5Schema: object) { strict: false, // Allow additional properties that are not in schema unicodeRegExp: false, loadSchema: async (uri) => { - // Check cache first to prevent infinite loops + const release = await fetchSchemaMutex.acquire(); + if (schemaCache.has(uri)) { log.info(`Loading cached schema: ${uri}`); - - try { - const schema = await schemaCache.get(uri)!; - return schema; - } catch { - schemaCache.delete(uri); - } + return schemaCache.get(uri)!; } - log.info(`Loading external schema: ${uri}`); - let fetchSchema: Promise; - try { - if (uri.includes("adaptive-card.json")) { - // Special handling for Adaptive Card schema to fix unsupported "id" property - // According to the JSON Schema spec Draft 06 (used by Adaptive Card schema), - // "$id" should be used instead of "id" - fetchSchema = fetchCdn(uri) - .then((response) => { - if ("id" in response && typeof response.id === "string") { - const typedResponse = response as Record; - typedResponse.$id = response.id; - delete typedResponse.id; - } - return response; - }); - } else { - fetchSchema = fetchCdn(uri); + log.info(`Loading external schema: ${uri}`); + const schema = await fetchCdn(uri) as AnySchemaObject; + + // Special handling for Adaptive Card schema to fix unsupported "id" property + // According to the JSON Schema spec Draft 06 (used by Adaptive Card schema), + // "$id" should be used instead of "id" + if (uri.includes("adaptive-card.json") && typeof schema.id === "string") { + schema.$id = schema.id; + delete schema.id; } - schemaCache.set(uri, fetchSchema); - return fetchSchema; + schemaCache.set(uri, schema); + + return schema; } catch (error) { log.warn(`Failed to load external schema ${uri}:` + `${error instanceof Error ? error.message : String(error)}`); throw error; + } finally { + release(); } }, }); diff --git a/test/fixtures/manifest_validation/schema.json b/test/fixtures/manifest_validation/schema.json new file mode 100644 index 0000000..bcc2288 --- /dev/null +++ b/test/fixtures/manifest_validation/schema.json @@ -0,0 +1,46 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "required": ["_version", "sap.app"], + "properties": { + "_version": { + "type": "string" + }, + "sap.app": { + "type": "object", + "required": ["id", "type", "applicationVersion"], + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string" + }, + "title": { + "type": "string" + }, + "description": { + "type": "string" + }, + "applicationVersion": { + "type": "object", + "required": ["version"], + "properties": { + "version": { + "type": "string" + } + } + }, + "dataSources": { + "type": "object" + } + } + }, + "sap.ui": { + "type": "object" + }, + "sap.ui5": { + "type": "object" + } + } +} diff --git a/test/fixtures/manifest_validation/valid-manifest.json b/test/fixtures/manifest_validation/valid-manifest.json new file mode 100644 index 0000000..69fca7c --- /dev/null +++ b/test/fixtures/manifest_validation/valid-manifest.json @@ -0,0 +1,26 @@ +{ + "_version": "1.59.0", + "sap.app": { + "id": "com.example.app", + "type": "application", + "applicationVersion": { + "version": "1.0.0" + } + }, + "sap.ui": { + "technology": "UI5", + "deviceTypes": { + "desktop": true, + "tablet": true, + "phone": true + } + }, + "sap.ui5": { + "dependencies": { + "minUI5Version": "1.120.0", + "libs": { + "sap.m": {} + } + } + } +} diff --git a/test/lib/tools/run_manifest_validation/runValidation.integration.ts b/test/lib/tools/run_manifest_validation/runValidation.integration.ts new file mode 100644 index 0000000..15eeab3 --- /dev/null +++ b/test/lib/tools/run_manifest_validation/runValidation.integration.ts @@ -0,0 +1,45 @@ +import anyTest, {TestFn} from "ava"; +import * as sinon from "sinon"; +import esmock from "esmock"; +import {readFile} from "fs/promises"; +import path from "path"; +import {fileURLToPath} from "url"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const fixturesPath = path.join(__dirname, "..", "..", "..", "fixtures", "manifest_validation"); + +const test = anyTest as TestFn<{ + sinon: sinon.SinonSandbox; + runValidation: typeof import("../../../../src/tools/run_manifest_validation/runValidation.js").default; +}>; + +test.beforeEach(async (t) => { + t.context.sinon = sinon.createSandbox(); + + const schemaFixture = await readFile(path.join(fixturesPath, "schema.json"), "utf-8"); + const getManifestSchemaStub = t.context.sinon.stub().resolves(JSON.parse(schemaFixture)); + + // Import the runValidation function + t.context.runValidation = (await esmock( + "../../../../src/tools/run_manifest_validation/runValidation.js", { + "../../../../src/utils/ui5Manifest.js": { + getManifestSchema: getManifestSchemaStub, + }, + } + )).default; +}); + +test.afterEach.always((t) => { + t.context.sinon.restore(); +}); + +test("runValidation successfully validates valid manifest", async (t) => { + const {runValidation} = t.context; + + const result = await runValidation(path.join(fixturesPath, "valid-manifest.json")); + + t.deepEqual(result, { + isValid: true, + errors: [], + }); +}); diff --git a/test/lib/tools/run_manifest_validation/runValidation.ts b/test/lib/tools/run_manifest_validation/runValidation.ts new file mode 100644 index 0000000..7152910 --- /dev/null +++ b/test/lib/tools/run_manifest_validation/runValidation.ts @@ -0,0 +1,256 @@ +import anyTest, {TestFn} from "ava"; +import * as sinon from "sinon"; +import esmock from "esmock"; +import {readFile} from "fs/promises"; + +const test = anyTest as TestFn<{ + sinon: sinon.SinonSandbox; + readFileStub: sinon.SinonStub; + manifestFileContent: string; + getManifestSchemaStub: sinon.SinonStub; + fetchCdnStub: sinon.SinonStub; + runValidation: typeof import("../../../../src/tools/run_manifest_validation/runValidation.js").default; +}>; + +test.beforeEach(async (t) => { + t.context.sinon = sinon.createSandbox(); + t.context.manifestFileContent = ""; + + // Create a stub that only intercepts specific manifest paths, otherwise calls real readFile + t.context.readFileStub = t.context.sinon.stub().callsFake(async ( + path: string, + encoding?: BufferEncoding | null + ) => { + // Only handle specific manifest paths that we explicitly stub + if (path === "/path/to/manifest.json") { + // These will be handled by withArgs() stubs below + return t.context.manifestFileContent; + } + // For all other files (including AJV schema files), call the real readFile + return readFile(path, encoding ?? "utf-8"); + }); + + t.context.getManifestSchemaStub = t.context.sinon.stub(); + t.context.fetchCdnStub = t.context.sinon.stub(); + + // Import the runValidation function + t.context.runValidation = (await esmock( + "../../../../src/tools/run_manifest_validation/runValidation.js", { + "fs/promises": { + readFile: t.context.readFileStub, + }, + "../../../../src/utils/ui5Manifest.js": { + getManifestSchema: t.context.getManifestSchemaStub, + }, + "../../../../src/utils/cdnHelper.js": { + fetchCdn: t.context.fetchCdnStub, + }, + } + )).default; +}); + +test.afterEach.always((t) => { + t.context.sinon.restore(); +}); + +test("runValidation successfully validates valid manifest", async (t) => { + const {runValidation, getManifestSchemaStub} = t.context; + + // Stub the readFile function to return a valid manifest + const validManifest = { + "sap.app": { + id: "my.app.id", + type: "application", + }, + }; + t.context.manifestFileContent = JSON.stringify(validManifest); + + getManifestSchemaStub.resolves({ + type: "object", + properties: { + "sap.app": { + type: "object", + properties: { + id: {type: "string"}, + type: {type: "string"}, + }, + required: ["id", "type"], + }, + }, + required: ["sap.app"], + }); + + const result = await runValidation("/path/to/manifest.json"); + + t.deepEqual(result, { + isValid: true, + errors: [], + }); +}); + +test("runValidation successfully validates invalid manifest", async (t) => { + const {runValidation, getManifestSchemaStub} = t.context; + + // Stub the readFile function to return an invalid manifest + const invalidManifest = { + "sap.app": { + id: "my.app.id", + // Missing required field "type" + }, + }; + t.context.manifestFileContent = JSON.stringify(invalidManifest); + + getManifestSchemaStub.resolves({ + type: "object", + properties: { + "sap.app": { + type: "object", + properties: { + id: {type: "string"}, + type: {type: "string"}, + }, + required: ["id", "type"], + }, + }, + required: ["sap.app"], + additionalProperties: false, + }); + + const result = await runValidation("/path/to/manifest.json"); + + t.deepEqual(result, { + isValid: false, + errors: [ + { + params: {missingProperty: "type"}, + keyword: "required", + instancePath: "/sap.app", + schemaPath: "#/properties/sap.app/required", + message: "must have required property 'type'", + propertyName: undefined, + schema: undefined, + parentSchema: undefined, + data: undefined, + }, + ], + }); +}); + +test("runValidation throws error when manifest file path is not correct", async (t) => { + const {runValidation, readFileStub} = t.context; + + // Stub the readFile function to throw an error + readFileStub.rejects(new Error("File not found")); + + await t.throwsAsync(async () => { + const result = await runValidation("/nonexistent/path"); + return result; + }, { + instanceOf: Error, + message: /Failed to read manifest file at .+: .+/, + }); +}); + +test("runValidation throws error when manifest file content is invalid JSON", async (t) => { + const {runValidation} = t.context; + + t.context.manifestFileContent = "Invalid JSON Content"; + + await t.throwsAsync(async () => { + const result = await runValidation("/path/to/manifest.json"); + return result; + }, { + instanceOf: Error, + message: /Failed to parse manifest file at .+ as JSON: .+/, + }); +}); + +test("runValidation throws error when schema validation function cannot be compiled", async (t) => { + const {runValidation, getManifestSchemaStub} = t.context; + + t.context.manifestFileContent = JSON.stringify({}); + getManifestSchemaStub.resolves(null); // Simulate invalid schema + + await t.throwsAsync(async () => { + const result = await runValidation("/path/to/manifest.json"); + return result; + }, { + instanceOf: Error, + message: /Failed to create UI5 manifest validate function: .+/, + }); +}); + +test("runValidation successfully validates valid manifest against external schema", async (t) => { + const {runValidation, getManifestSchemaStub, fetchCdnStub} = t.context; + + t.context.manifestFileContent = JSON.stringify({ + "sap.app": { + id: "my.app.id", + type: "application", + }, + }); + + // Schema that references an external schema + getManifestSchemaStub.resolves({ + type: "object", + properties: { + "sap.app": { + $ref: "externalSchema.json", + }, + }, + required: ["sap.app"], + }); + + // Stub the readFile function to return the external schema when requested + const externalSchema = { + type: "object", + properties: { + id: {type: "string"}, + type: {type: "string"}, + }, + required: ["id", "type"], + }; + fetchCdnStub.withArgs("externalSchema.json") + .resolves(externalSchema); + + const result = await runValidation("/path/to/manifest.json"); + + t.deepEqual(result, { + isValid: true, + errors: [], + }); +}); + +test("runValidation throws error when external schema cannot be fetched", async (t) => { + const {runValidation, getManifestSchemaStub, fetchCdnStub} = t.context; + + t.context.manifestFileContent = JSON.stringify({ + "sap.app": { + id: "my.app.id", + type: "application", + }, + }); + + // Schema that references an external schema + getManifestSchemaStub.resolves({ + type: "object", + properties: { + "sap.app": { + $ref: "externalSchema.json", + }, + }, + required: ["sap.app"], + }); + + // Stub the fetchCdn function to throw an error when fetching the external schema + fetchCdnStub.withArgs("externalSchema.json") + .rejects(new Error("Failed to fetch external schema")); + + await t.throwsAsync(async () => { + const result = await runValidation("/path/to/manifest.json"); + return result; + }, { + instanceOf: Error, + message: /Failed to create UI5 manifest validate function: .+/, + }); +}); From 3c6b99c1308f277a40a78cf776ac5f5f1e4e7dad Mon Sep 17 00:00:00 2001 From: Petar Dimov Date: Thu, 30 Oct 2025 10:30:18 +0200 Subject: [PATCH 09/14] refactor: Add comment containing link to AdaptiveCards issue --- src/tools/run_manifest_validation/runValidation.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/tools/run_manifest_validation/runValidation.ts b/src/tools/run_manifest_validation/runValidation.ts index e374918..cb3e45a 100644 --- a/src/tools/run_manifest_validation/runValidation.ts +++ b/src/tools/run_manifest_validation/runValidation.ts @@ -37,6 +37,7 @@ async function createUI5ManifestValidateFunction(ui5Schema: object) { // Special handling for Adaptive Card schema to fix unsupported "id" property // According to the JSON Schema spec Draft 06 (used by Adaptive Card schema), // "$id" should be used instead of "id" + // See https://github.com/microsoft/AdaptiveCards/issues/9274 if (uri.includes("adaptive-card.json") && typeof schema.id === "string") { schema.$id = schema.id; delete schema.id; From 4c49e479f1ace8031a2f97cdeaecd59700ec8740 Mon Sep 17 00:00:00 2001 From: Petar Dimov Date: Thu, 30 Oct 2025 10:34:50 +0200 Subject: [PATCH 10/14] fix(package.json): List ajv as dependency --- npm-shrinkwrap.json | 1 + package.json | 1 + 2 files changed, 2 insertions(+) diff --git a/npm-shrinkwrap.json b/npm-shrinkwrap.json index 406c2c4..5a23082 100644 --- a/npm-shrinkwrap.json +++ b/npm-shrinkwrap.json @@ -13,6 +13,7 @@ "@ui5/linter": "^1.20.2", "@ui5/logger": "^4.0.2", "@ui5/project": "^4.0.8", + "ajv": "^8.17.1", "async-mutex": "^0.5.0", "ejs": "^3.1.10", "execa": "^9.6.0", diff --git a/package.json b/package.json index 7d0481f..2fb30cc 100644 --- a/package.json +++ b/package.json @@ -78,6 +78,7 @@ "@ui5/linter": "^1.20.2", "@ui5/logger": "^4.0.2", "@ui5/project": "^4.0.8", + "ajv": "^8.17.1", "async-mutex": "^0.5.0", "ejs": "^3.1.10", "execa": "^9.6.0", From 4c87335a87875bb27a4777c4741d34d92c6dd0b6 Mon Sep 17 00:00:00 2001 From: Petar Dimov Date: Thu, 30 Oct 2025 16:02:27 +0200 Subject: [PATCH 11/14] fix(runValidation): Resolve meta schemas paths using import.meta.resolve --- src/tools/run_manifest_validation/runValidation.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/tools/run_manifest_validation/runValidation.ts b/src/tools/run_manifest_validation/runValidation.ts index cb3e45a..070296e 100644 --- a/src/tools/run_manifest_validation/runValidation.ts +++ b/src/tools/run_manifest_validation/runValidation.ts @@ -6,14 +6,15 @@ import {getLogger} from "@ui5/logger"; import {InvalidInputError} from "../../utils.js"; import {getManifestSchema} from "../../utils/ui5Manifest.js"; import {Mutex} from "async-mutex"; +import {fileURLToPath} from "url"; const log = getLogger("tools:run_manifest_validation:runValidation"); const schemaCache = new Map(); const fetchSchemaMutex = new Mutex(); const AJV_SCHEMA_PATHS = { - draft06: "node_modules/ajv/dist/refs/json-schema-draft-06.json", - draft07: "node_modules/ajv/dist/refs/json-schema-draft-07.json", + draft06: fileURLToPath(import.meta.resolve("ajv/dist/refs/json-schema-draft-06.json")), + draft07: fileURLToPath(import.meta.resolve("ajv/dist/refs/json-schema-draft-07.json")), } as const; async function createUI5ManifestValidateFunction(ui5Schema: object) { From 0f4d9df74ee79d2e8a4d2aa76cbeb02d05d938e8 Mon Sep 17 00:00:00 2001 From: Petar Dimov Date: Thu, 30 Oct 2025 16:13:53 +0200 Subject: [PATCH 12/14] fix(runValidation): Throw error if manifest path is not absolute --- .../run_manifest_validation/runValidation.ts | 5 ++++ src/tools/run_manifest_validation/schema.ts | 2 +- .../run_manifest_validation/runValidation.ts | 28 ++++++++++++------- 3 files changed, 24 insertions(+), 11 deletions(-) diff --git a/src/tools/run_manifest_validation/runValidation.ts b/src/tools/run_manifest_validation/runValidation.ts index 070296e..64fbd76 100644 --- a/src/tools/run_manifest_validation/runValidation.ts +++ b/src/tools/run_manifest_validation/runValidation.ts @@ -7,6 +7,7 @@ import {InvalidInputError} from "../../utils.js"; import {getManifestSchema} from "../../utils/ui5Manifest.js"; import {Mutex} from "async-mutex"; import {fileURLToPath} from "url"; +import {isAbsolute} from "path"; const log = getLogger("tools:run_manifest_validation:runValidation"); const schemaCache = new Map(); @@ -80,6 +81,10 @@ async function readManifest(path: string) { let content: string; let json: object; + if (!isAbsolute(path)) { + throw new InvalidInputError(`The manifest path must be absolute: '${path}'`); + } + try { content = await readFile(path, "utf-8"); } catch (error) { diff --git a/src/tools/run_manifest_validation/schema.ts b/src/tools/run_manifest_validation/schema.ts index 5220ebb..2d0bdd7 100644 --- a/src/tools/run_manifest_validation/schema.ts +++ b/src/tools/run_manifest_validation/schema.ts @@ -2,7 +2,7 @@ import {z} from "zod"; export const inputSchema = { manifestPath: z.string() - .describe("Path to the manifest file to validate."), + .describe("Absolute path to the manifest file to validate."), }; export const outputSchema = { diff --git a/test/lib/tools/run_manifest_validation/runValidation.ts b/test/lib/tools/run_manifest_validation/runValidation.ts index 7152910..bfe206d 100644 --- a/test/lib/tools/run_manifest_validation/runValidation.ts +++ b/test/lib/tools/run_manifest_validation/runValidation.ts @@ -2,6 +2,7 @@ import anyTest, {TestFn} from "ava"; import * as sinon from "sinon"; import esmock from "esmock"; import {readFile} from "fs/promises"; +import {InvalidInputError} from "../../../../src/utils.js"; const test = anyTest as TestFn<{ sinon: sinon.SinonSandbox; @@ -136,6 +137,17 @@ test("runValidation successfully validates invalid manifest", async (t) => { }); }); +test("runValidation throws error when manifest file path is not absolute", async (t) => { + const {runValidation} = t.context; + + await t.throwsAsync(async () => { + return await runValidation("relativeManifest.json"); + }, { + instanceOf: InvalidInputError, + message: "The manifest path must be absolute: 'relativeManifest.json'", + }); +}); + test("runValidation throws error when manifest file path is not correct", async (t) => { const {runValidation, readFileStub} = t.context; @@ -143,10 +155,9 @@ test("runValidation throws error when manifest file path is not correct", async readFileStub.rejects(new Error("File not found")); await t.throwsAsync(async () => { - const result = await runValidation("/nonexistent/path"); - return result; + return await runValidation("/nonexistent/path"); }, { - instanceOf: Error, + instanceOf: InvalidInputError, message: /Failed to read manifest file at .+: .+/, }); }); @@ -157,10 +168,9 @@ test("runValidation throws error when manifest file content is invalid JSON", as t.context.manifestFileContent = "Invalid JSON Content"; await t.throwsAsync(async () => { - const result = await runValidation("/path/to/manifest.json"); - return result; + return await runValidation("/path/to/manifest.json"); }, { - instanceOf: Error, + instanceOf: InvalidInputError, message: /Failed to parse manifest file at .+ as JSON: .+/, }); }); @@ -172,8 +182,7 @@ test("runValidation throws error when schema validation function cannot be compi getManifestSchemaStub.resolves(null); // Simulate invalid schema await t.throwsAsync(async () => { - const result = await runValidation("/path/to/manifest.json"); - return result; + return await runValidation("/path/to/manifest.json"); }, { instanceOf: Error, message: /Failed to create UI5 manifest validate function: .+/, @@ -247,8 +256,7 @@ test("runValidation throws error when external schema cannot be fetched", async .rejects(new Error("Failed to fetch external schema")); await t.throwsAsync(async () => { - const result = await runValidation("/path/to/manifest.json"); - return result; + return await runValidation("/path/to/manifest.json"); }, { instanceOf: Error, message: /Failed to create UI5 manifest validate function: .+/, From 593f749b7307d2115682823d0def18b304006fd3 Mon Sep 17 00:00:00 2001 From: Petar Dimov Date: Wed, 5 Nov 2025 11:13:48 +0200 Subject: [PATCH 13/14] fix(run_manifest_validation): Normalize manifest path --- src/tools/run_manifest_validation/index.ts | 5 ++- .../tools/run_manifest_validation/index.ts | 40 +++++++++++++++++++ 2 files changed, 43 insertions(+), 2 deletions(-) diff --git a/src/tools/run_manifest_validation/index.ts b/src/tools/run_manifest_validation/index.ts index 6ae8284..cb20800 100644 --- a/src/tools/run_manifest_validation/index.ts +++ b/src/tools/run_manifest_validation/index.ts @@ -6,7 +6,7 @@ import {RegisterTool} from "../../registerTools.js"; const log = getLogger("tools:run_manifest_validation"); -export default function registerTool(registerTool: RegisterTool, _context: Context) { +export default function registerTool(registerTool: RegisterTool, context: Context) { registerTool("run_manifest_validation", { title: "Manifest Validation", description: @@ -22,7 +22,8 @@ export default function registerTool(registerTool: RegisterTool, _context: Conte }, async ({manifestPath}) => { log.info(`Running manifest validation on ${manifestPath}...`); - const result = await runValidation(manifestPath); + const normalizedManifestPath = await context.normalizePath(manifestPath); + const result = await runValidation(normalizedManifestPath); return { content: [{ diff --git a/test/lib/tools/run_manifest_validation/index.ts b/test/lib/tools/run_manifest_validation/index.ts index e29af86..1b01d58 100644 --- a/test/lib/tools/run_manifest_validation/index.ts +++ b/test/lib/tools/run_manifest_validation/index.ts @@ -145,3 +145,43 @@ test("run_manifest_validation tool handles errors correctly", async (t) => { message: errorMessage, }); }); + +test("run_manifest_validation tool normalizes manifest path before validation", async (t) => { + const { + registerToolCallback, + registerRunManifestValidationTool, + runValidationStub, + } = t.context; + + // Setup runValidation to return a sample result + const sampleResult = { + valid: true, + issues: [], + }; + runValidationStub.resolves(sampleResult); + + class CustomTestContext extends TestContext { + async normalizePath(path: string): Promise { + return Promise.resolve(`/normalized${path}`); + } + } + + // Register the tool and capture the execute function + registerRunManifestValidationTool(registerToolCallback, new CustomTestContext()); + const executeFunction = registerToolCallback.firstCall.args[2]; + + const mockExtra = { + signal: new AbortController().signal, + requestId: "test-request-id", + sendNotification: t.context.sinon.stub(), + sendRequest: t.context.sinon.stub(), + }; + + // Execute the tool + const manifestPath = "/path/to/manifest.json"; + await executeFunction({manifestPath}, mockExtra); + + // Verify that runValidation was called with the normalized path + t.true(runValidationStub.calledOnce); + t.is(runValidationStub.firstCall.args[0], "/normalized/path/to/manifest.json"); +}); From 7d71c5e3a7b7505d4379792d759add618185edce Mon Sep 17 00:00:00 2001 From: Petar Dimov Date: Mon, 10 Nov 2025 14:20:17 +0200 Subject: [PATCH 14/14] refactor: Fetch concrete manifest schema --- .../run_manifest_validation/runValidation.ts | 4 +- src/utils/ui5Manifest.ts | 87 ++++++++++++++++--- .../missing-version-manifest.json | 25 ++++++ .../runValidation.integration.ts | 80 +++++++++++++++-- .../run_manifest_validation/runValidation.ts | 15 +++- test/lib/utils/ui5Manifest.ts | 67 +++++++++++--- 6 files changed, 244 insertions(+), 34 deletions(-) create mode 100644 test/fixtures/manifest_validation/missing-version-manifest.json diff --git a/src/tools/run_manifest_validation/runValidation.ts b/src/tools/run_manifest_validation/runValidation.ts index 64fbd76..75716e7 100644 --- a/src/tools/run_manifest_validation/runValidation.ts +++ b/src/tools/run_manifest_validation/runValidation.ts @@ -4,7 +4,7 @@ import Ajv2020, {AnySchemaObject} from "ajv/dist/2020.js"; import {readFile} from "fs/promises"; import {getLogger} from "@ui5/logger"; import {InvalidInputError} from "../../utils.js"; -import {getManifestSchema} from "../../utils/ui5Manifest.js"; +import {getManifestSchema, getManifestVersion} from "../../utils/ui5Manifest.js"; import {Mutex} from "async-mutex"; import {fileURLToPath} from "url"; import {isAbsolute} from "path"; @@ -106,7 +106,7 @@ export default async function runValidation(manifestPath: string): Promise(); const fetchSchemaMutex = new Mutex(); @@ -12,6 +12,10 @@ let UI5ToManifestVersionMapping: Record | null = null; const MAPPING_URL = "https://raw.githubusercontent.com/SAP/ui5-manifest/main/mapping.json"; const ui5ToManifestVersionMappingMutex = new Mutex(); +function getSchemaURL(manifestVersion: string) { + return `https://raw.githubusercontent.com/SAP/ui5-manifest/v${manifestVersion}/schema.json`; +} + async function getUI5toManifestVersionMapping() { const release = await ui5ToManifestVersionMappingMutex.acquire(); @@ -43,8 +47,9 @@ async function fetchSchema(manifestVersion: string) { } log.info(`Fetching schema for manifest version: ${manifestVersion}`); - const schema = await fetchCdn(LATEST_SCHEMA_URL); - log.info(`Fetched UI5 manifest schema from ${LATEST_SCHEMA_URL}`); + const schemaURL = getSchemaURL(manifestVersion); + const schema = await fetchCdn(schemaURL); + log.info(`Fetched UI5 manifest schema from ${schemaURL}`); schemaCache.set(manifestVersion, schema); @@ -54,6 +59,74 @@ async function fetchSchema(manifestVersion: string) { } } +/** + * Get the manifest schema for a specific manifest version. + * @param manifestVersion The manifest version + * @returns The manifest schema + * @throws Error if the manifest version is unsupported + */ +export async function getManifestSchema(manifestVersion: string) { + if (semver.lt(manifestVersion, "1.48.0")) { + throw new Error( + `Manifest version '${manifestVersion}' is not supported. Please upgrade to a newer one.` + ); + } + + try { + return await fetchSchema(manifestVersion); + } catch (error) { + let supportedVersions; + + try { + const versionMap = await getUI5toManifestVersionMapping(); + supportedVersions = Object.values(versionMap); + } catch (error) { + log.warn(`Failed to fetch UI5 to manifest version mapping: ` + + `${error instanceof Error ? error.message : String(error)}`); + }; + + // try to hint which versions are supported + if (supportedVersions && !supportedVersions.includes(manifestVersion)) { + throw new Error( + `Failed to fetch schema for manifest version '${manifestVersion}': ` + + `This version is not supported. ` + + `Supported versions are: ${supportedVersions.join(", ")}. ` + + `${error instanceof Error ? error.message : String(error)}` + ); + } + + throw new Error( + `Failed to fetch schema for manifest version '${manifestVersion}': ` + + `${error instanceof Error ? error.message : String(error)}` + ); + } +} + +/** + * Get the manifest version from the manifest object. + * @param manifest The manifest object + * @returns The manifest version + * @throws Error if the manifest version is missing or invalid + */ +export function getManifestVersion(manifest: object) { + if (!("_version" in manifest)) { + throw new Error("Manifest does not contain a '_version' property."); + } + + if (typeof manifest._version !== "string") { + throw new Error("Manifest '_version' property is not a string."); + } + + if (!semver.valid(manifest._version)) { + throw new Error("Manifest '_version' property is not a valid semantic version."); + } + + return manifest._version; +} + +/** + * @returns The latest manifest version + */ export async function getLatestManifestVersion() { const versionMap = await getUI5toManifestVersionMapping(); @@ -63,11 +136,3 @@ export async function getLatestManifestVersion() { return versionMap.latest; } - -export async function getManifestSchema(manifestVersion: string) { - if (manifestVersion !== "latest") { - throw new Error(`Only 'latest' manifest version is supported, but got '${manifestVersion}'.`); - } - - return await fetchSchema(manifestVersion); -} diff --git a/test/fixtures/manifest_validation/missing-version-manifest.json b/test/fixtures/manifest_validation/missing-version-manifest.json new file mode 100644 index 0000000..2b73004 --- /dev/null +++ b/test/fixtures/manifest_validation/missing-version-manifest.json @@ -0,0 +1,25 @@ +{ + "sap.app": { + "id": "com.example.app", + "type": "application", + "applicationVersion": { + "version": "1.0.0" + } + }, + "sap.ui": { + "technology": "UI5", + "deviceTypes": { + "desktop": true, + "tablet": true, + "phone": true + } + }, + "sap.ui5": { + "dependencies": { + "minUI5Version": "1.120.0", + "libs": { + "sap.m": {} + } + } + } +} diff --git a/test/lib/tools/run_manifest_validation/runValidation.integration.ts b/test/lib/tools/run_manifest_validation/runValidation.integration.ts index 15eeab3..06076aa 100644 --- a/test/lib/tools/run_manifest_validation/runValidation.integration.ts +++ b/test/lib/tools/run_manifest_validation/runValidation.integration.ts @@ -7,23 +7,26 @@ import {fileURLToPath} from "url"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const fixturesPath = path.join(__dirname, "..", "..", "..", "fixtures", "manifest_validation"); +const schemaFixture = JSON.parse(await readFile(path.join(fixturesPath, "schema.json"), "utf-8")); const test = anyTest as TestFn<{ sinon: sinon.SinonSandbox; runValidation: typeof import("../../../../src/tools/run_manifest_validation/runValidation.js").default; + fetchCdnStub: sinon.SinonStub; }>; test.beforeEach(async (t) => { t.context.sinon = sinon.createSandbox(); - const schemaFixture = await readFile(path.join(fixturesPath, "schema.json"), "utf-8"); - const getManifestSchemaStub = t.context.sinon.stub().resolves(JSON.parse(schemaFixture)); + t.context.fetchCdnStub = t.context.sinon.stub(); - // Import the runValidation function + // Import the runValidation function with cdnHelper mocked globally t.context.runValidation = (await esmock( - "../../../../src/tools/run_manifest_validation/runValidation.js", { - "../../../../src/utils/ui5Manifest.js": { - getManifestSchema: getManifestSchemaStub, + "../../../../src/tools/run_manifest_validation/runValidation.js", + {}, + { + "../../../../src/utils/cdnHelper.js": { + fetchCdn: t.context.fetchCdnStub, }, } )).default; @@ -34,7 +37,15 @@ test.afterEach.always((t) => { }); test("runValidation successfully validates valid manifest", async (t) => { - const {runValidation} = t.context; + const {runValidation, fetchCdnStub} = t.context; + + fetchCdnStub.withArgs("https://raw.githubusercontent.com/SAP/ui5-manifest/main/mapping.json") + .resolves({ + "1.59.0": "1.59.0", + }); + + fetchCdnStub.withArgs("https://raw.githubusercontent.com/SAP/ui5-manifest/v1.59.0/schema.json") + .resolves(schemaFixture); const result = await runValidation(path.join(fixturesPath, "valid-manifest.json")); @@ -43,3 +54,58 @@ test("runValidation successfully validates valid manifest", async (t) => { errors: [], }); }); + +test("runValidation successfully validates valid manifest after first attempt ending with exception", async (t) => { + const {runValidation, fetchCdnStub} = t.context; + + fetchCdnStub.withArgs("https://raw.githubusercontent.com/SAP/ui5-manifest/main/mapping.json") + .resolves({ + "1.59.0": "1.59.0", + }); + + fetchCdnStub.withArgs("https://raw.githubusercontent.com/SAP/ui5-manifest/v1.59.0/schema.json") + .resolves(schemaFixture); + + await t.throwsAsync(async () => { + await runValidation(path.join(fixturesPath, "missing-version-manifest.json")); + }, { + message: "Manifest does not contain a '_version' property.", + }); + + const result = await runValidation(path.join(fixturesPath, "valid-manifest.json")); + + t.deepEqual(result, { + isValid: true, + errors: [], + }); +}); + +test("runValidation successfully validates valid manifest after first attempt ending with schema fetch error", + async (t) => { + const {runValidation, fetchCdnStub} = t.context; + + fetchCdnStub.withArgs("https://raw.githubusercontent.com/SAP/ui5-manifest/main/mapping.json") + .resolves({ + "1.59.0": "1.59.0", + }); + + fetchCdnStub.withArgs("https://raw.githubusercontent.com/SAP/ui5-manifest/v1.59.0/schema.json") + .onFirstCall() + .rejects(new Error("Failed to fetch schema")) + .onSecondCall() + .resolves(schemaFixture); + + await t.throwsAsync(async () => { + await runValidation(path.join(fixturesPath, "valid-manifest.json")); + }, { + message: "Failed to fetch schema for manifest version '1.59.0': Failed to fetch schema", + }); + + const result = await runValidation(path.join(fixturesPath, "valid-manifest.json")); + + t.deepEqual(result, { + isValid: true, + errors: [], + }); + } +); diff --git a/test/lib/tools/run_manifest_validation/runValidation.ts b/test/lib/tools/run_manifest_validation/runValidation.ts index bfe206d..8170e28 100644 --- a/test/lib/tools/run_manifest_validation/runValidation.ts +++ b/test/lib/tools/run_manifest_validation/runValidation.ts @@ -59,6 +59,7 @@ test("runValidation successfully validates valid manifest", async (t) => { // Stub the readFile function to return a valid manifest const validManifest = { + "_version": "1.0.0", "sap.app": { id: "my.app.id", type: "application", @@ -69,6 +70,7 @@ test("runValidation successfully validates valid manifest", async (t) => { getManifestSchemaStub.resolves({ type: "object", properties: { + "_version": {type: "string"}, "sap.app": { type: "object", properties: { @@ -79,6 +81,7 @@ test("runValidation successfully validates valid manifest", async (t) => { }, }, required: ["sap.app"], + additionalProperties: false, }); const result = await runValidation("/path/to/manifest.json"); @@ -94,6 +97,7 @@ test("runValidation successfully validates invalid manifest", async (t) => { // Stub the readFile function to return an invalid manifest const invalidManifest = { + "_version": "1.0.0", "sap.app": { id: "my.app.id", // Missing required field "type" @@ -104,6 +108,7 @@ test("runValidation successfully validates invalid manifest", async (t) => { getManifestSchemaStub.resolves({ type: "object", properties: { + "_version": {type: "string"}, "sap.app": { type: "object", properties: { @@ -178,7 +183,9 @@ test("runValidation throws error when manifest file content is invalid JSON", as test("runValidation throws error when schema validation function cannot be compiled", async (t) => { const {runValidation, getManifestSchemaStub} = t.context; - t.context.manifestFileContent = JSON.stringify({}); + t.context.manifestFileContent = JSON.stringify({ + _version: "1.0.0", + }); getManifestSchemaStub.resolves(null); // Simulate invalid schema await t.throwsAsync(async () => { @@ -193,6 +200,7 @@ test("runValidation successfully validates valid manifest against external schem const {runValidation, getManifestSchemaStub, fetchCdnStub} = t.context; t.context.manifestFileContent = JSON.stringify({ + "_version": "1.0.0", "sap.app": { id: "my.app.id", type: "application", @@ -203,11 +211,13 @@ test("runValidation successfully validates valid manifest against external schem getManifestSchemaStub.resolves({ type: "object", properties: { + "_version": {type: "string"}, "sap.app": { $ref: "externalSchema.json", }, }, required: ["sap.app"], + additionalProperties: false, }); // Stub the readFile function to return the external schema when requested @@ -234,6 +244,7 @@ test("runValidation throws error when external schema cannot be fetched", async const {runValidation, getManifestSchemaStub, fetchCdnStub} = t.context; t.context.manifestFileContent = JSON.stringify({ + "_version": "1.0.0", "sap.app": { id: "my.app.id", type: "application", @@ -244,11 +255,13 @@ test("runValidation throws error when external schema cannot be fetched", async getManifestSchemaStub.resolves({ type: "object", properties: { + "_version": {type: "string"}, "sap.app": { $ref: "externalSchema.json", }, }, required: ["sap.app"], + additionalProperties: false, }); // Stub the fetchCdn function to throw an error when fetching the external schema diff --git a/test/lib/utils/ui5Manifest.ts b/test/lib/utils/ui5Manifest.ts index e3905af..6c85df1 100644 --- a/test/lib/utils/ui5Manifest.ts +++ b/test/lib/utils/ui5Manifest.ts @@ -98,28 +98,38 @@ test("getLatestManifestVersion handles missing latest version", async (t) => { t.true(fetchCdnStub.calledOnce); }); -test("getManifestSchema throws error for unsupported versions", async (t) => { +test("getManifestSchema throws error for unsupported versions 1.x.x versions", async (t) => { const {getManifestSchema} = t.context; await t.throwsAsync( async () => { - await getManifestSchema("1.78.0"); + await getManifestSchema("1.47.0"); }, { - message: "Only 'latest' manifest version is supported, but got '1.78.0'.", + message: "Manifest version '1.47.0' is not supported. Please upgrade to a newer one.", } ); + + await t.notThrowsAsync(async () => { + await getManifestSchema("1.48.0"); + }); + + await t.notThrowsAsync(async () => { + await getManifestSchema("2.0.0"); + }); }); -test("getManifestSchema fetches schema for 'latest' version", async (t) => { +test("getManifestSchema fetches schema for specific version", async (t) => { const {fetchCdnStub, getManifestSchema} = t.context; const mockSchema = { $schema: "http://json-schema.org/draft-07/schema#", type: "object", }; - fetchCdnStub.resolves(mockSchema); - const schema = await getManifestSchema("latest"); + fetchCdnStub.withArgs("https://raw.githubusercontent.com/SAP/ui5-manifest/v1.48.0/schema.json") + .resolves(mockSchema); + + const schema = await getManifestSchema("1.48.0"); t.deepEqual(schema, mockSchema); t.true(fetchCdnStub.calledOnce); @@ -131,10 +141,12 @@ test("getManifestSchema uses cache on subsequent calls", async (t) => { $schema: "http://json-schema.org/draft-07/schema#", type: "object", }; - fetchCdnStub.resolves(mockSchema); - const schema1 = await getManifestSchema("latest"); - const schema2 = await getManifestSchema("latest"); + fetchCdnStub.withArgs("https://raw.githubusercontent.com/SAP/ui5-manifest/v1.48.0/schema.json") + .resolves(mockSchema); + + const schema1 = await getManifestSchema("1.48.0"); + const schema2 = await getManifestSchema("1.48.0"); t.deepEqual(schema1, mockSchema); t.deepEqual(schema2, mockSchema); @@ -145,15 +157,44 @@ test("getManifestSchema handles fetch errors", async (t) => { const {fetchCdnStub, getManifestSchema} = t.context; // Mock fetch error - fetchCdnStub.rejects(new Error("Network error")); + fetchCdnStub.withArgs("https://raw.githubusercontent.com/SAP/ui5-manifest/main/mapping.json") + .rejects(new Error("Mapping.json error")); + + fetchCdnStub.withArgs("https://raw.githubusercontent.com/SAP/ui5-manifest/v1.48.0/schema.json") + .rejects(new Error("Network error")); await t.throwsAsync( async () => { - await getManifestSchema("latest"); + await getManifestSchema("1.48.0"); }, { - message: "Network error", + message: "Failed to fetch schema for manifest version '1.48.0': Network error", } ); - t.true(fetchCdnStub.calledOnce); + t.true(fetchCdnStub.calledTwice); +}); + +test("getManifestSchema handles fetch errors and gives more details about supported versions", async (t) => { + const {fetchCdnStub, getManifestSchema} = t.context; + + // Mock fetch error + fetchCdnStub.withArgs("https://raw.githubusercontent.com/SAP/ui5-manifest/main/mapping.json") + .resolves({ + "1.49.0": "1.49.0", + "1.50.0": "1.50.0", + }); + + fetchCdnStub.withArgs("https://raw.githubusercontent.com/SAP/ui5-manifest/v1.48.0/schema.json") + .rejects(new Error("Network error")); + + await t.throwsAsync( + async () => { + await getManifestSchema("1.48.0"); + }, + { + message: "Failed to fetch schema for manifest version '1.48.0': " + + "This version is not supported. Supported versions are: 1.49.0, 1.50.0. Network error", + } + ); + t.true(fetchCdnStub.calledTwice); });