From 6fed17519ae5cb18484bd9e981d5dede67b53ae8 Mon Sep 17 00:00:00 2001 From: Petar Dimov Date: Tue, 21 Oct 2025 14:41:57 +0300 Subject: [PATCH 01/25] 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 9f27db0e..2e1fc3fa 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 00000000..5914c91c --- /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 00000000..ff19ac78 --- /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 00000000..1601e3c1 --- /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/25] 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 ff19ac78..b0355fbb 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/25] 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 5914c91c..6ae82842 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 00000000..e29af86e --- /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/25] 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 217f2282..e17cde25 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/25] docs(README.md): List run_manifest_validation --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index af39d6eb..96fea669 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/25] 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 e17cde25..3f2bb7e7 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 fece738a..e3905af1 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/25] 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 b0355fbb..dadd9148 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 1601e3c1..5220ebbe 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/25] 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 e480aae1..6372d662 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 dadd9148..e374918c 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 00000000..bcc2288e --- /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 00000000..69fca7c7 --- /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 00000000..15eeab3b --- /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 00000000..71529108 --- /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/25] 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 e374918c..cb3e45a2 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/25] 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 406c2c4c..5a23082c 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 7d0481f7..2fb30cc4 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/25] 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 cb3e45a2..070296e7 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/25] 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 070296e7..64fbd76a 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 5220ebbe..2d0bdd79 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 71529108..bfe206d9 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/25] 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 6ae82842..cb208002 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 e29af86e..1b01d58d 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/25] 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 64fbd76a..75716e76 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 00000000..2b73004a --- /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 15eeab3b..06076aa9 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 bfe206d9..8170e285 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 e3905af1..6c85df15 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); }); From 05113b330eaabb345e97a9c607c50958fb9515ca Mon Sep 17 00:00:00 2001 From: Petar Dimov Date: Mon, 17 Nov 2025 13:44:49 +0200 Subject: [PATCH 15/25] fix(runValidation): Properly release mutex --- src/tools/run_manifest_validation/runValidation.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/tools/run_manifest_validation/runValidation.ts b/src/tools/run_manifest_validation/runValidation.ts index 75716e76..6af7b9f5 100644 --- a/src/tools/run_manifest_validation/runValidation.ts +++ b/src/tools/run_manifest_validation/runValidation.ts @@ -27,12 +27,12 @@ async function createUI5ManifestValidateFunction(ui5Schema: object) { loadSchema: async (uri) => { const release = await fetchSchemaMutex.acquire(); - if (schemaCache.has(uri)) { - log.info(`Loading cached schema: ${uri}`); - return schemaCache.get(uri)!; - } - try { + if (schemaCache.has(uri)) { + log.info(`Loading cached schema: ${uri}`); + return schemaCache.get(uri)!; + } + log.info(`Loading external schema: ${uri}`); const schema = await fetchCdn(uri) as AnySchemaObject; From be513fb9e7451d157c131d01722dec5812623e75 Mon Sep 17 00:00:00 2001 From: Petar Dimov Date: Mon, 17 Nov 2025 15:07:35 +0200 Subject: [PATCH 16/25] fix(ui5Manifest): Throw errors for versions lt 1.68.0 --- src/utils/ui5Manifest.ts | 46 +++++++-------- .../manifest_validation/valid-manifest.json | 2 +- .../runValidation.integration.ts | 15 ++--- test/lib/utils/ui5Manifest.ts | 58 ++++++++++++------- 4 files changed, 69 insertions(+), 52 deletions(-) diff --git a/src/utils/ui5Manifest.ts b/src/utils/ui5Manifest.ts index 8aa68b2e..a92f8000 100644 --- a/src/utils/ui5Manifest.ts +++ b/src/utils/ui5Manifest.ts @@ -59,15 +59,33 @@ async function fetchSchema(manifestVersion: string) { } } +async function failWithSupportedVersionsHint(errorMessage: string): Promise { + let supportedVersions; + + try { + const versionMap = await getUI5toManifestVersionMapping(); + supportedVersions = Object.values(versionMap).filter((version) => semver.gte(version, "1.68.0")); + } catch (_) { + supportedVersions = null; + }; + + throw new Error( + errorMessage + + (supportedVersions ? + `\nSupported versions are: ${supportedVersions.join(", ")}.` : + "") + ); +} + /** * 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( +export async function getManifestSchema(manifestVersion: string): Promise { + if (semver.lt(manifestVersion, "1.68.0")) { + return failWithSupportedVersionsHint( `Manifest version '${manifestVersion}' is not supported. Please upgrade to a newer one.` ); } @@ -75,27 +93,7 @@ export async function getManifestSchema(manifestVersion: string) { 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( + return failWithSupportedVersionsHint( `Failed to fetch schema for manifest version '${manifestVersion}': ` + `${error instanceof Error ? error.message : String(error)}` ); diff --git a/test/fixtures/manifest_validation/valid-manifest.json b/test/fixtures/manifest_validation/valid-manifest.json index 69fca7c7..837b4c1b 100644 --- a/test/fixtures/manifest_validation/valid-manifest.json +++ b/test/fixtures/manifest_validation/valid-manifest.json @@ -1,5 +1,5 @@ { - "_version": "1.59.0", + "_version": "1.79.0", "sap.app": { "id": "com.example.app", "type": "application", diff --git a/test/lib/tools/run_manifest_validation/runValidation.integration.ts b/test/lib/tools/run_manifest_validation/runValidation.integration.ts index 06076aa9..20ae8ef7 100644 --- a/test/lib/tools/run_manifest_validation/runValidation.integration.ts +++ b/test/lib/tools/run_manifest_validation/runValidation.integration.ts @@ -41,10 +41,10 @@ test("runValidation successfully validates valid manifest", async (t) => { fetchCdnStub.withArgs("https://raw.githubusercontent.com/SAP/ui5-manifest/main/mapping.json") .resolves({ - "1.59.0": "1.59.0", + "1.79.0": "1.79.0", }); - fetchCdnStub.withArgs("https://raw.githubusercontent.com/SAP/ui5-manifest/v1.59.0/schema.json") + fetchCdnStub.withArgs("https://raw.githubusercontent.com/SAP/ui5-manifest/v1.79.0/schema.json") .resolves(schemaFixture); const result = await runValidation(path.join(fixturesPath, "valid-manifest.json")); @@ -60,10 +60,10 @@ test("runValidation successfully validates valid manifest after first attempt en fetchCdnStub.withArgs("https://raw.githubusercontent.com/SAP/ui5-manifest/main/mapping.json") .resolves({ - "1.59.0": "1.59.0", + "1.79.0": "1.79.0", }); - fetchCdnStub.withArgs("https://raw.githubusercontent.com/SAP/ui5-manifest/v1.59.0/schema.json") + fetchCdnStub.withArgs("https://raw.githubusercontent.com/SAP/ui5-manifest/v1.79.0/schema.json") .resolves(schemaFixture); await t.throwsAsync(async () => { @@ -86,10 +86,10 @@ test("runValidation successfully validates valid manifest after first attempt en fetchCdnStub.withArgs("https://raw.githubusercontent.com/SAP/ui5-manifest/main/mapping.json") .resolves({ - "1.59.0": "1.59.0", + "1.79.0": "1.79.0", }); - fetchCdnStub.withArgs("https://raw.githubusercontent.com/SAP/ui5-manifest/v1.59.0/schema.json") + fetchCdnStub.withArgs("https://raw.githubusercontent.com/SAP/ui5-manifest/v1.79.0/schema.json") .onFirstCall() .rejects(new Error("Failed to fetch schema")) .onSecondCall() @@ -98,7 +98,8 @@ test("runValidation successfully validates valid manifest after first attempt en 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", + message: "Failed to fetch schema for manifest version '1.79.0': Failed to fetch schema" + + "\nSupported versions are: 1.79.0.", }); const result = await runValidation(path.join(fixturesPath, "valid-manifest.json")); diff --git a/test/lib/utils/ui5Manifest.ts b/test/lib/utils/ui5Manifest.ts index 6c85df15..68ee7df1 100644 --- a/test/lib/utils/ui5Manifest.ts +++ b/test/lib/utils/ui5Manifest.ts @@ -99,19 +99,37 @@ test("getLatestManifestVersion handles missing latest version", async (t) => { }); test("getManifestSchema throws error for unsupported versions 1.x.x versions", async (t) => { - const {getManifestSchema} = t.context; + const {getManifestSchema, fetchCdnStub} = t.context; await t.throwsAsync( async () => { - await getManifestSchema("1.47.0"); + await getManifestSchema("1.67.0"); }, { - message: "Manifest version '1.47.0' is not supported. Please upgrade to a newer one.", + message: "Manifest version '1.67.0' is not supported. Please upgrade to a newer one.", + } + ); + + fetchCdnStub.withArgs("https://raw.githubusercontent.com/SAP/ui5-manifest/main/mapping.json") + .resolves({ + "1.55.0": "1.55.0", + "1.67.0": "1.67.0", + "1.68.0": "1.68.0", + "1.69.0": "1.69.0", + }); + + await t.throwsAsync( + async () => { + await getManifestSchema("1.45.0"); + }, + { + message: "Manifest version '1.45.0' is not supported. Please upgrade to a newer one." + + "\nSupported versions are: 1.68.0, 1.69.0.", } ); await t.notThrowsAsync(async () => { - await getManifestSchema("1.48.0"); + await getManifestSchema("1.68.0"); }); await t.notThrowsAsync(async () => { @@ -122,14 +140,14 @@ test("getManifestSchema throws error for unsupported versions 1.x.x versions", a test("getManifestSchema fetches schema for specific version", async (t) => { const {fetchCdnStub, getManifestSchema} = t.context; const mockSchema = { - $schema: "http://json-schema.org/draft-07/schema#", + $schema: "https://json-schema.org/draft/2020-12/schema", type: "object", }; - fetchCdnStub.withArgs("https://raw.githubusercontent.com/SAP/ui5-manifest/v1.48.0/schema.json") + fetchCdnStub.withArgs("https://raw.githubusercontent.com/SAP/ui5-manifest/v1.68.0/schema.json") .resolves(mockSchema); - const schema = await getManifestSchema("1.48.0"); + const schema = await getManifestSchema("1.68.0"); t.deepEqual(schema, mockSchema); t.true(fetchCdnStub.calledOnce); @@ -138,15 +156,15 @@ test("getManifestSchema fetches schema for specific version", async (t) => { test("getManifestSchema uses cache on subsequent calls", async (t) => { const {fetchCdnStub, getManifestSchema} = t.context; const mockSchema = { - $schema: "http://json-schema.org/draft-07/schema#", + $schema: "https://json-schema.org/draft/2020-12/schema", type: "object", }; - fetchCdnStub.withArgs("https://raw.githubusercontent.com/SAP/ui5-manifest/v1.48.0/schema.json") + fetchCdnStub.withArgs("https://raw.githubusercontent.com/SAP/ui5-manifest/v1.68.0/schema.json") .resolves(mockSchema); - const schema1 = await getManifestSchema("1.48.0"); - const schema2 = await getManifestSchema("1.48.0"); + const schema1 = await getManifestSchema("1.68.0"); + const schema2 = await getManifestSchema("1.68.0"); t.deepEqual(schema1, mockSchema); t.deepEqual(schema2, mockSchema); @@ -160,15 +178,15 @@ test("getManifestSchema handles fetch errors", async (t) => { 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") + fetchCdnStub.withArgs("https://raw.githubusercontent.com/SAP/ui5-manifest/v1.68.0/schema.json") .rejects(new Error("Network error")); await t.throwsAsync( async () => { - await getManifestSchema("1.48.0"); + await getManifestSchema("1.68.0"); }, { - message: "Failed to fetch schema for manifest version '1.48.0': Network error", + message: "Failed to fetch schema for manifest version '1.68.0': Network error", } ); t.true(fetchCdnStub.calledTwice); @@ -180,20 +198,20 @@ test("getManifestSchema handles fetch errors and gives more details about suppor // 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", + "1.70.0": "1.70.0", + "1.71.0": "1.71.0", }); - fetchCdnStub.withArgs("https://raw.githubusercontent.com/SAP/ui5-manifest/v1.48.0/schema.json") + fetchCdnStub.withArgs("https://raw.githubusercontent.com/SAP/ui5-manifest/v1.69.0/schema.json") .rejects(new Error("Network error")); await t.throwsAsync( async () => { - await getManifestSchema("1.48.0"); + await getManifestSchema("1.69.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", + message: "Failed to fetch schema for manifest version '1.69.0': Network error" + + "\nSupported versions are: 1.70.0, 1.71.0.", } ); t.true(fetchCdnStub.calledTwice); From a950b4b027b71b3766051cac7e4730f3186d9206 Mon Sep 17 00:00:00 2001 From: Petar Dimov Date: Mon, 17 Nov 2025 15:29:37 +0200 Subject: [PATCH 17/25] fix(runValidation): Include ajv-formats --- npm-shrinkwrap.json | 18 ++++++++++++++++++ package.json | 1 + .../run_manifest_validation/runValidation.ts | 4 ++++ 3 files changed, 23 insertions(+) diff --git a/npm-shrinkwrap.json b/npm-shrinkwrap.json index 5a23082c..1b2b09b7 100644 --- a/npm-shrinkwrap.json +++ b/npm-shrinkwrap.json @@ -14,6 +14,7 @@ "@ui5/logger": "^4.0.2", "@ui5/project": "^4.0.8", "ajv": "^8.17.1", + "ajv-formats": "^3.0.1", "async-mutex": "^0.5.0", "ejs": "^3.1.10", "execa": "^9.6.0", @@ -9447,6 +9448,23 @@ "ajv": ">=5.0.0" } }, + "node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, "node_modules/ansi-regex": { "version": "6.2.0", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.0.tgz", diff --git a/package.json b/package.json index 2fb30cc4..8a14c04f 100644 --- a/package.json +++ b/package.json @@ -79,6 +79,7 @@ "@ui5/logger": "^4.0.2", "@ui5/project": "^4.0.8", "ajv": "^8.17.1", + "ajv-formats": "^3.0.1", "async-mutex": "^0.5.0", "ejs": "^3.1.10", "execa": "^9.6.0", diff --git a/src/tools/run_manifest_validation/runValidation.ts b/src/tools/run_manifest_validation/runValidation.ts index 6af7b9f5..0e958e1f 100644 --- a/src/tools/run_manifest_validation/runValidation.ts +++ b/src/tools/run_manifest_validation/runValidation.ts @@ -1,6 +1,7 @@ import {fetchCdn} from "../../utils/cdnHelper.js"; import {RunSchemaValidationResult} from "./schema.js"; import Ajv2020, {AnySchemaObject} from "ajv/dist/2020.js"; +import addFormats from "ajv-formats"; import {readFile} from "fs/promises"; import {getLogger} from "@ui5/logger"; import {InvalidInputError} from "../../utils.js"; @@ -58,6 +59,9 @@ async function createUI5ManifestValidateFunction(ui5Schema: object) { } }, }); + + addFormats.default(ajv); + const draft06MetaSchema = JSON.parse( await readFile(AJV_SCHEMA_PATHS.draft06, "utf-8") ) as AnySchemaObject; From ae0b43188006af59f9c28f9337ac86e28af1059e Mon Sep 17 00:00:00 2001 From: Petar Dimov Date: Mon, 17 Nov 2025 15:46:41 +0200 Subject: [PATCH 18/25] refactor(ui5Manifest): Enhance more errors with supported versions info --- src/tools/run_manifest_validation/runValidation.ts | 2 +- src/utils/ui5Manifest.ts | 8 ++++---- .../run_manifest_validation/runValidation.integration.ts | 3 ++- 3 files changed, 7 insertions(+), 6 deletions(-) diff --git a/src/tools/run_manifest_validation/runValidation.ts b/src/tools/run_manifest_validation/runValidation.ts index 0e958e1f..99a9abcf 100644 --- a/src/tools/run_manifest_validation/runValidation.ts +++ b/src/tools/run_manifest_validation/runValidation.ts @@ -110,7 +110,7 @@ export default async function runValidation(manifestPath: string): Promise { if (!("_version" in manifest)) { - throw new Error("Manifest does not contain a '_version' property."); + return failWithSupportedVersionsHint("Manifest does not contain a '_version' property."); } if (typeof manifest._version !== "string") { - throw new Error("Manifest '_version' property is not a string."); + return failWithSupportedVersionsHint("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 failWithSupportedVersionsHint("Manifest '_version' property is not a valid semantic version."); } return manifest._version; diff --git a/test/lib/tools/run_manifest_validation/runValidation.integration.ts b/test/lib/tools/run_manifest_validation/runValidation.integration.ts index 20ae8ef7..f26bad93 100644 --- a/test/lib/tools/run_manifest_validation/runValidation.integration.ts +++ b/test/lib/tools/run_manifest_validation/runValidation.integration.ts @@ -69,7 +69,8 @@ test("runValidation successfully validates valid manifest after first attempt en await t.throwsAsync(async () => { await runValidation(path.join(fixturesPath, "missing-version-manifest.json")); }, { - message: "Manifest does not contain a '_version' property.", + message: "Manifest does not contain a '_version' property." + + "\nSupported versions are: 1.79.0.", }); const result = await runValidation(path.join(fixturesPath, "valid-manifest.json")); From 7d32b8f888258c3e72d8b74d84f90e7bcb472d22 Mon Sep 17 00:00:00 2001 From: Petar Dimov Date: Mon, 24 Nov 2025 15:01:09 +0200 Subject: [PATCH 19/25] test(runValidation): Increase coverage for external schemas --- .../run_manifest_validation/runValidation.ts | 91 +++++++++++++++++++ 1 file changed, 91 insertions(+) diff --git a/test/lib/tools/run_manifest_validation/runValidation.ts b/test/lib/tools/run_manifest_validation/runValidation.ts index 8170e285..be942737 100644 --- a/test/lib/tools/run_manifest_validation/runValidation.ts +++ b/test/lib/tools/run_manifest_validation/runValidation.ts @@ -275,3 +275,94 @@ test("runValidation throws error when external schema cannot be fetched", async message: /Failed to create UI5 manifest validate function: .+/, }); }); + +test("runValidation uses cache on subsequent calls for external schemas", async (t) => { + const {runValidation, getManifestSchemaStub, fetchCdnStub} = t.context; + + t.context.manifestFileContent = JSON.stringify({ + "_version": "1.0.0", + "sap.app": { + id: "my.app.id", + type: "application", + }, + }); + + // Schema that references an external schema + getManifestSchemaStub.resolves({ + type: "object", + properties: { + "_version": {type: "string"}, + "sap.app": { + $ref: "externalSchema.json", + }, + }, + required: ["sap.app"], + additionalProperties: false, + }); + + // Stub the fetchCdn 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 result1 = await runValidation("/path/to/manifest.json"); + const result2 = await runValidation("/path/to/manifest.json"); + + t.deepEqual(result1, { + isValid: true, + errors: [], + }); + t.deepEqual(result2, { + isValid: true, + errors: [], + }); + t.true(fetchCdnStub.calledOnce); // External schema fetched only once +}); + +test("runValidation patches external adaptive-card.json schema", async (t) => { + const {runValidation, getManifestSchemaStub, fetchCdnStub} = t.context; + + t.context.manifestFileContent = JSON.stringify({ + _version: "1.0.0", + adaptiveCards: { + type: "AdaptiveCard", + }, + }); + + // Schema that references the adaptive-card.json schema + getManifestSchemaStub.resolves({ + type: "object", + properties: { + _version: {type: "string"}, + adaptiveCards: { + $ref: "https://adaptivecards.io/schemas/adaptive-card.json", + }, + }, + }); + + // Stub the fetchCdn function to return the adaptive-card.json schema when requested + const adaptiveCardSchema = { + type: "object", + id: "https://adaptivecards.io/schemas/adaptive-card.json", // Note the "id" property + properties: { + type: {type: "string"}, + }, + required: ["type"], + }; + fetchCdnStub.withArgs("https://adaptivecards.io/schemas/adaptive-card.json") + .resolves(adaptiveCardSchema); + + const result = await runValidation("/path/to/manifest.json"); + + t.deepEqual(result, { + isValid: true, + errors: [], + }); +}); From aefa858e4dc0ba1fb82a3c07bd45aeda475dd7bb Mon Sep 17 00:00:00 2001 From: Petar Dimov Date: Mon, 24 Nov 2025 15:35:44 +0200 Subject: [PATCH 20/25] refactor(runValidation): Add explanation why meta schemas are needed --- src/tools/run_manifest_validation/runValidation.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/tools/run_manifest_validation/runValidation.ts b/src/tools/run_manifest_validation/runValidation.ts index 99a9abcf..5f427d46 100644 --- a/src/tools/run_manifest_validation/runValidation.ts +++ b/src/tools/run_manifest_validation/runValidation.ts @@ -69,6 +69,9 @@ async function createUI5ManifestValidateFunction(ui5Schema: object) { await readFile(AJV_SCHEMA_PATHS.draft07, "utf-8") ) as AnySchemaObject; + // Add meta-schemas for draft-06 and draft-07. + // These are required to support schemas that reference these drafts, + // for example the Adaptive Card schema and some sap.bpa.task properties. ajv.addMetaSchema(draft06MetaSchema, "http://json-schema.org/draft-06/schema#"); ajv.addMetaSchema(draft07MetaSchema, "http://json-schema.org/draft-07/schema#"); From 005701ba2c7f7bf7b739e7508d932b1765c0dc3f Mon Sep 17 00:00:00 2001 From: Petar Dimov Date: Mon, 24 Nov 2025 15:49:21 +0200 Subject: [PATCH 21/25] refactor(runValidation): Add explanation for unicodeRegExp setting --- src/tools/run_manifest_validation/runValidation.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/tools/run_manifest_validation/runValidation.ts b/src/tools/run_manifest_validation/runValidation.ts index 5f427d46..c9f5a42a 100644 --- a/src/tools/run_manifest_validation/runValidation.ts +++ b/src/tools/run_manifest_validation/runValidation.ts @@ -24,7 +24,8 @@ 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, + unicodeRegExp: false, // Don't use Unicode-aware regular expressions, + // otherwise compilation fails with "Invalid escape" errors loadSchema: async (uri) => { const release = await fetchSchemaMutex.acquire(); From 53beef20e0dc6d96068f65deca286bf0d191524d Mon Sep 17 00:00:00 2001 From: Petar Dimov Date: Mon, 24 Nov 2025 16:40:24 +0200 Subject: [PATCH 22/25] refactor(runValidation): Add coment why strict flag of ajv is off --- src/tools/run_manifest_validation/index.ts | 2 +- src/tools/run_manifest_validation/runValidation.ts | 10 +++++++--- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/src/tools/run_manifest_validation/index.ts b/src/tools/run_manifest_validation/index.ts index cb208002..d89e9c2f 100644 --- a/src/tools/run_manifest_validation/index.ts +++ b/src/tools/run_manifest_validation/index.ts @@ -10,7 +10,7 @@ export default function registerTool(registerTool: RegisterTool, context: Contex registerTool("run_manifest_validation", { title: "Manifest Validation", description: - "Validates UI5 manifest file." + + "Validates UI5 manifest file. " + "After making changes, you should always run the validation again " + "to verify that no new problems have been introduced.", annotations: { diff --git a/src/tools/run_manifest_validation/runValidation.ts b/src/tools/run_manifest_validation/runValidation.ts index c9f5a42a..1822b106 100644 --- a/src/tools/run_manifest_validation/runValidation.ts +++ b/src/tools/run_manifest_validation/runValidation.ts @@ -22,10 +22,14 @@ const AJV_SCHEMA_PATHS = { 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, // Don't use Unicode-aware regular expressions, + // Collect all errors, not just the first one + allErrors: true, + // Allow additional properties that are not in schema such as "i18n", + // otherwise compilation fails + strict: false, + // Don't use Unicode-aware regular expressions, // otherwise compilation fails with "Invalid escape" errors + unicodeRegExp: false, loadSchema: async (uri) => { const release = await fetchSchemaMutex.acquire(); From 95c35274f8f9b4f19b2e8350f9d0451df0d0bbeb Mon Sep 17 00:00:00 2001 From: Petar Dimov Date: Mon, 24 Nov 2025 17:14:04 +0200 Subject: [PATCH 23/25] refactor(ui5Manifest): Add comments for lowest supported version 1.68.0 --- src/utils/ui5Manifest.ts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/utils/ui5Manifest.ts b/src/utils/ui5Manifest.ts index 1d5a2e6f..c72dbe53 100644 --- a/src/utils/ui5Manifest.ts +++ b/src/utils/ui5Manifest.ts @@ -12,6 +12,9 @@ let UI5ToManifestVersionMapping: Record | null = null; const MAPPING_URL = "https://raw.githubusercontent.com/SAP/ui5-manifest/main/mapping.json"; const ui5ToManifestVersionMappingMutex = new Mutex(); +// Manifests prior to 1.68.0 use older meta-schema, which is not supported by the current implementation +const LOWEST_SUPPORTED_MANIFEST_VERSION = "1.68.0"; + function getSchemaURL(manifestVersion: string) { return `https://raw.githubusercontent.com/SAP/ui5-manifest/v${manifestVersion}/schema.json`; } @@ -64,7 +67,9 @@ async function failWithSupportedVersionsHint(errorMessage: string): Promise semver.gte(version, "1.68.0")); + supportedVersions = Object.values(versionMap).filter( + (version) => semver.gte(version, LOWEST_SUPPORTED_MANIFEST_VERSION) + ); } catch (_) { supportedVersions = null; }; @@ -84,7 +89,7 @@ async function failWithSupportedVersionsHint(errorMessage: string): Promise { - if (semver.lt(manifestVersion, "1.68.0")) { + if (semver.lt(manifestVersion, LOWEST_SUPPORTED_MANIFEST_VERSION)) { return failWithSupportedVersionsHint( `Manifest version '${manifestVersion}' is not supported. Please upgrade to a newer one.` ); From 6c7eae5dd1cba4eb490d6888d28e08cf0d42fca6 Mon Sep 17 00:00:00 2001 From: Petar Dimov Date: Tue, 25 Nov 2025 11:36:11 +0200 Subject: [PATCH 24/25] fix(run_manifest_validation): Mark the tool as read-only --- src/tools/run_manifest_validation/index.ts | 2 +- test/lib/tools/run_manifest_validation/index.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/tools/run_manifest_validation/index.ts b/src/tools/run_manifest_validation/index.ts index d89e9c2f..f2e5e61d 100644 --- a/src/tools/run_manifest_validation/index.ts +++ b/src/tools/run_manifest_validation/index.ts @@ -15,7 +15,7 @@ export default function registerTool(registerTool: RegisterTool, context: Contex "to verify that no new problems have been introduced.", annotations: { title: "Manifest Validation", - readOnlyHint: false, + readOnlyHint: true, }, inputSchema, outputSchema, diff --git a/test/lib/tools/run_manifest_validation/index.ts b/test/lib/tools/run_manifest_validation/index.ts index 1b01d58d..6b5e11cc 100644 --- a/test/lib/tools/run_manifest_validation/index.ts +++ b/test/lib/tools/run_manifest_validation/index.ts @@ -74,7 +74,7 @@ test("registerRunManifestValidationTool registers the tool with correct paramete 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); + t.true(toolConfig?.annotations?.readOnlyHint); }); test("run_manifest_validation tool returns validation result on success", async (t) => { From 286fbe7c57bf831d800572cb797ebc39093c0989 Mon Sep 17 00:00:00 2001 From: Petar Dimov Date: Tue, 25 Nov 2025 12:47:40 +0200 Subject: [PATCH 25/25] refactor(runValidation): Remove undefined properties from the result --- .../run_manifest_validation/runValidation.ts | 5 +-- src/tools/run_manifest_validation/schema.ts | 9 ------ .../run_manifest_validation/runValidation.ts | 31 +++++++++---------- 3 files changed, 16 insertions(+), 29 deletions(-) diff --git a/src/tools/run_manifest_validation/runValidation.ts b/src/tools/run_manifest_validation/runValidation.ts index 1822b106..489359e3 100644 --- a/src/tools/run_manifest_validation/runValidation.ts +++ b/src/tools/run_manifest_validation/runValidation.ts @@ -135,7 +135,7 @@ export default async function runValidation(manifestPath: string): Promise { + const errors = validationErrors.map((error): RunSchemaValidationResult["errors"][number] => { return { keyword: error.keyword ?? "", instancePath: error.instancePath ?? "", @@ -143,9 +143,6 @@ export default async function runValidation(manifestPath: string): Promise { const invalidManifest = { "_version": "1.0.0", "sap.app": { - id: "my.app.id", - // Missing required field "type" + Bad: "value", }, }; t.context.manifestFileContent = JSON.stringify(invalidManifest); @@ -108,18 +107,13 @@ test("runValidation successfully validates invalid manifest", async (t) => { getManifestSchemaStub.resolves({ type: "object", properties: { - "_version": {type: "string"}, "sap.app": { type: "object", - properties: { - id: {type: "string"}, - type: {type: "string"}, + propertyNames: { + pattern: "^[a-z]+$", // Enforce lowercase property names }, - required: ["id", "type"], }, }, - required: ["sap.app"], - additionalProperties: false, }); const result = await runValidation("/path/to/manifest.json"); @@ -128,15 +122,20 @@ test("runValidation successfully validates invalid manifest", async (t) => { isValid: false, errors: [ { - params: {missingProperty: "type"}, - keyword: "required", + params: {pattern: "^[a-z]+$"}, + keyword: "pattern", + instancePath: "/sap.app", + schemaPath: "#/properties/sap.app/propertyNames/pattern", + message: "must match pattern \"^[a-z]+$\"", + propertyName: "Bad", + }, + { + params: {propertyName: "Bad"}, + keyword: "propertyNames", instancePath: "/sap.app", - schemaPath: "#/properties/sap.app/required", - message: "must have required property 'type'", + schemaPath: "#/properties/sap.app/propertyNames", + message: "property name must be valid", propertyName: undefined, - schema: undefined, - parentSchema: undefined, - data: undefined, }, ], });