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 diff --git a/npm-shrinkwrap.json b/npm-shrinkwrap.json index 406c2c4c..1b2b09b7 100644 --- a/npm-shrinkwrap.json +++ b/npm-shrinkwrap.json @@ -13,6 +13,8 @@ "@ui5/linter": "^1.20.2", "@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", @@ -9446,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 7d0481f7..8a14c04f 100644 --- a/package.json +++ b/package.json @@ -78,6 +78,8 @@ "@ui5/linter": "^1.20.2", "@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/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/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..f2e5e61d --- /dev/null +++ b/src/tools/run_manifest_validation/index.ts @@ -0,0 +1,36 @@ +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", { + title: "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: true, + }, + inputSchema, + outputSchema, + }, async ({manifestPath}) => { + log.info(`Running manifest validation on ${manifestPath}...`); + + const normalizedManifestPath = await context.normalizePath(manifestPath); + const result = await runValidation(normalizedManifestPath); + + 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..489359e3 --- /dev/null +++ b/src/tools/run_manifest_validation/runValidation.ts @@ -0,0 +1,155 @@ +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"; +import {getManifestSchema, getManifestVersion} 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(); +const fetchSchemaMutex = new Mutex(); + +const AJV_SCHEMA_PATHS = { + 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) { + try { + const ajv = new Ajv2020.default({ + // 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(); + + 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; + + // 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; + } + + 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(); + } + }, + }); + + addFormats.default(ajv); + + 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; + + // 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#"); + + const validate = await ajv.compileAsync(ui5Schema); + + 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) { + 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) { + 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 manifestVersion = await getManifestVersion(manifest); + log.info(`Using manifest version: ${manifestVersion}`); + const ui5ManifestSchema = await getManifestSchema(manifestVersion); + const validate = await createUI5ManifestValidateFunction(ui5ManifestSchema); + 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): RunSchemaValidationResult["errors"][number] => { + return { + keyword: error.keyword ?? "", + instancePath: error.instancePath ?? "", + schemaPath: error.schemaPath ?? "", + params: error.params ?? {}, + propertyName: error.propertyName, + message: error.message, + }; + }); + + 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..8aa7804e --- /dev/null +++ b/src/tools/run_manifest_validation/schema.ts @@ -0,0 +1,31 @@ +import {z} from "zod"; + +export const inputSchema = { + manifestPath: z.string() + .describe("Absolute 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."), + }) + ).describe("Array of validation error objects as returned by Ajv."), +}; +const _outputSchemaObject = z.object(outputSchema); +export type RunSchemaValidationResult = z.infer; diff --git a/src/utils/ui5Manifest.ts b/src/utils/ui5Manifest.ts index 2ae3f979..c72dbe53 100644 --- a/src/utils/ui5Manifest.ts +++ b/src/utils/ui5Manifest.ts @@ -1,15 +1,137 @@ +import {getLogger} from "@ui5/logger"; import {fetchCdn} from "./cdnHelper.js"; +import {Mutex} from "async-mutex"; +import semver from "semver"; +const log = getLogger("utils:ui5Manifest"); + +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(); + +// 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`; +} + +async function getUI5toManifestVersionMapping() { + const release = await ui5ToManifestVersionMappingMutex.acquire(); + + try { + if (UI5ToManifestVersionMapping) { + log.info("Loading cached UI5 to manifest version mapping"); + return UI5ToManifestVersionMapping; + } + + 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}`); + + UI5ToManifestVersionMapping = mapping as Record; -async function getUI5toManifestVersionMap() { - const mapping = await fetchCdn(MAPPING_URL); + return UI5ToManifestVersionMapping; + } finally { + release(); + } +} + +async function fetchSchema(manifestVersion: string) { + 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}`); + const schemaURL = getSchemaURL(manifestVersion); + const schema = await fetchCdn(schemaURL); + log.info(`Fetched UI5 manifest schema from ${schemaURL}`); + + schemaCache.set(manifestVersion, schema); + + return schema; + } finally { + release(); + } +} + +async function failWithSupportedVersionsHint(errorMessage: string): Promise { + let supportedVersions; + + try { + const versionMap = await getUI5toManifestVersionMapping(); + supportedVersions = Object.values(versionMap).filter( + (version) => semver.gte(version, LOWEST_SUPPORTED_MANIFEST_VERSION) + ); + } 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): Promise { + if (semver.lt(manifestVersion, LOWEST_SUPPORTED_MANIFEST_VERSION)) { + return failWithSupportedVersionsHint( + `Manifest version '${manifestVersion}' is not supported. Please upgrade to a newer one.` + ); + } + + try { + return await fetchSchema(manifestVersion); + } catch (error) { + return failWithSupportedVersionsHint( + `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 async function getManifestVersion(manifest: object): Promise { + if (!("_version" in manifest)) { + return failWithSupportedVersionsHint("Manifest does not contain a '_version' property."); + } + + if (typeof manifest._version !== "string") { + return failWithSupportedVersionsHint("Manifest '_version' property is not a string."); + } + + if (!semver.valid(manifest._version)) { + return failWithSupportedVersionsHint("Manifest '_version' property is not a valid semantic version."); + } - return mapping as Record; + return manifest._version; } +/** + * @returns The latest manifest version + */ 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/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/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..837b4c1b --- /dev/null +++ b/test/fixtures/manifest_validation/valid-manifest.json @@ -0,0 +1,26 @@ +{ + "_version": "1.79.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/index.ts b/test/lib/tools/run_manifest_validation/index.ts new file mode 100644 index 00000000..6b5e11cc --- /dev/null +++ b/test/lib/tools/run_manifest_validation/index.ts @@ -0,0 +1,187 @@ +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.true(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, + }); +}); + +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"); +}); 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..f26bad93 --- /dev/null +++ b/test/lib/tools/run_manifest_validation/runValidation.integration.ts @@ -0,0 +1,113 @@ +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 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(); + + t.context.fetchCdnStub = t.context.sinon.stub(); + + // Import the runValidation function with cdnHelper mocked globally + t.context.runValidation = (await esmock( + "../../../../src/tools/run_manifest_validation/runValidation.js", + {}, + { + "../../../../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, fetchCdnStub} = t.context; + + fetchCdnStub.withArgs("https://raw.githubusercontent.com/SAP/ui5-manifest/main/mapping.json") + .resolves({ + "1.79.0": "1.79.0", + }); + + 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")); + + t.deepEqual(result, { + isValid: true, + 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.79.0": "1.79.0", + }); + + fetchCdnStub.withArgs("https://raw.githubusercontent.com/SAP/ui5-manifest/v1.79.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." + + "\nSupported versions are: 1.79.0.", + }); + + 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.79.0": "1.79.0", + }); + + fetchCdnStub.withArgs("https://raw.githubusercontent.com/SAP/ui5-manifest/v1.79.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.79.0': Failed to fetch schema" + + "\nSupported versions are: 1.79.0.", + }); + + 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..0ecc3d35 --- /dev/null +++ b/test/lib/tools/run_manifest_validation/runValidation.ts @@ -0,0 +1,367 @@ +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; + 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 = { + "_version": "1.0.0", + "sap.app": { + id: "my.app.id", + type: "application", + }, + }; + t.context.manifestFileContent = JSON.stringify(validManifest); + + getManifestSchemaStub.resolves({ + type: "object", + properties: { + "_version": {type: "string"}, + "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: 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 = { + "_version": "1.0.0", + "sap.app": { + Bad: "value", + }, + }; + t.context.manifestFileContent = JSON.stringify(invalidManifest); + + getManifestSchemaStub.resolves({ + type: "object", + properties: { + "sap.app": { + type: "object", + propertyNames: { + pattern: "^[a-z]+$", // Enforce lowercase property names + }, + }, + }, + }); + + const result = await runValidation("/path/to/manifest.json"); + + t.deepEqual(result, { + isValid: false, + errors: [ + { + 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/propertyNames", + message: "property name must be valid", + propertyName: undefined, + }, + ], + }); +}); + +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; + + // Stub the readFile function to throw an error + readFileStub.rejects(new Error("File not found")); + + await t.throwsAsync(async () => { + return await runValidation("/nonexistent/path"); + }, { + instanceOf: InvalidInputError, + 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 () => { + return await runValidation("/path/to/manifest.json"); + }, { + instanceOf: InvalidInputError, + 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({ + _version: "1.0.0", + }); + getManifestSchemaStub.resolves(null); // Simulate invalid schema + + await t.throwsAsync(async () => { + return await runValidation("/path/to/manifest.json"); + }, { + 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({ + "_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 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({ + "_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 throw an error when fetching the external schema + fetchCdnStub.withArgs("externalSchema.json") + .rejects(new Error("Failed to fetch external schema")); + + await t.throwsAsync(async () => { + return await runValidation("/path/to/manifest.json"); + }, { + instanceOf: Error, + 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: [], + }); +}); diff --git a/test/lib/utils/ui5Manifest.ts b/test/lib/utils/ui5Manifest.ts index fece738a..68ee7df1 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,122 @@ test("getLatestManifestVersion handles missing latest version", async (t) => { ); t.true(fetchCdnStub.calledOnce); }); + +test("getManifestSchema throws error for unsupported versions 1.x.x versions", async (t) => { + const {getManifestSchema, fetchCdnStub} = t.context; + + await t.throwsAsync( + async () => { + await getManifestSchema("1.67.0"); + }, + { + 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.68.0"); + }); + + await t.notThrowsAsync(async () => { + await getManifestSchema("2.0.0"); + }); +}); + +test("getManifestSchema fetches schema for specific version", async (t) => { + const {fetchCdnStub, getManifestSchema} = t.context; + const mockSchema = { + $schema: "https://json-schema.org/draft/2020-12/schema", + type: "object", + }; + + fetchCdnStub.withArgs("https://raw.githubusercontent.com/SAP/ui5-manifest/v1.68.0/schema.json") + .resolves(mockSchema); + + const schema = await getManifestSchema("1.68.0"); + + 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: "https://json-schema.org/draft/2020-12/schema", + type: "object", + }; + + fetchCdnStub.withArgs("https://raw.githubusercontent.com/SAP/ui5-manifest/v1.68.0/schema.json") + .resolves(mockSchema); + + const schema1 = await getManifestSchema("1.68.0"); + const schema2 = await getManifestSchema("1.68.0"); + + 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.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.68.0/schema.json") + .rejects(new Error("Network error")); + + await t.throwsAsync( + async () => { + await getManifestSchema("1.68.0"); + }, + { + message: "Failed to fetch schema for manifest version '1.68.0': Network error", + } + ); + 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.70.0": "1.70.0", + "1.71.0": "1.71.0", + }); + + 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.69.0"); + }, + { + 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); +});