diff --git a/README.md b/README.md index 267fa8ea..0b91ba36 100644 --- a/README.md +++ b/README.md @@ -32,6 +32,7 @@ The UI5 [Model Context Protocol](https://modelcontextprotocol.io/) server offers - `get_typescript_conversion_guidelines`: Provides guidelines for converting UI5 applications and controls from JavaScript to TypeScript. - `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/docs/architecture.md b/docs/architecture.md index e319ebf0..ba3661a6 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -78,6 +78,15 @@ Tool Name | Description `get_typescript_conversion_guidelines` | Provides guidelines for converting UI5 applications and controls from JavaScript to TypeScript `get_version_info` | Retrieves version information for the UI5 framework `run_ui5_linter` | Integrates with `@ui5/linter` to analyze and report issues in UI5 code +`run_manifest_validation` | Validates the manifest against the UI5 Manifest schema + +### create_integration_card + +The `create_integration_card` tool is designed to scaffold new UI Integration Cards using predefined templates. It automates the setup process, ensuring that developers can quickly start building integration cards without manually configuring the project structure. + +Templates are stored in the `resources/` directory of the project. There is currently one template for every declarative card type (List Card, Table Card, etc.). + +For rendering the templates with the provided data, the [EJS](https://github.com/mde/ejs) templating engine is used. ### create_ui5_app @@ -187,6 +196,10 @@ Drawbacks of the current approach using the semantic model: The `get_guidelines` tool returns a single markdown resource containing best practices and guidelines for UI5 development, particularly targeted towards AI agents. The document can be found in the `resources/` directory of the project. +### get_integration_cards_guidelines + +The `get_integration_cards_guidelines` tool returns a single markdown resource containing best practices and guidelines for UI Integration Cards development. The content is particularly targeted towards AI agents. This document can be found in the `resources/` directory of the project. + ### get_project_info The `get_project_info` tool extracts metadata and configuration from a UI5 project. It provides insights into the project's structure, dependencies, and configuration settings, helping developers understand the context of their application. @@ -485,6 +498,107 @@ In the future, these guides should be moved into the UI5 linter project. See als } ``` +### run_manifest_validation + +The `run_manifest_validation` tool provides comprehensive schema validation for UI5 manifest files (`manifest.json`). It ensures that manifest files conform to the official UI5 Manifest JSON Schema, helping developers catch configuration errors early in the development process. + +#### Overview + +This tool uses the [Ajv JSON schema validator](https://www.npmjs.com/package/ajv) (specifically Ajv 2020-12) to perform validation against the official manifest schema. The schema is dynamically fetched from the [UI5 Manifest repository](https://github.com/UI5/manifest) based on the `_version` property declared in the manifest file. + +#### Schema Management + +**Version Detection:** +- The tool automatically detects the manifest version from the `_version` property +- If the `_version` property is missing, malformed, or not a valid semantic version, validation fails with a helpful error message listing supported versions +- The minimum supported manifest version is **1.68.0** (earlier versions use incompatible meta-schemas) + +**Schema Retrieval:** +- Schemas are fetched from: `https://raw.githubusercontent.com/UI5/manifest/v{version}/schema.json` +- A version mapping is maintained at: `https://raw.githubusercontent.com/UI5/manifest/main/mapping.json` +- Schemas are cached locally after first fetch to improve performance and reduce network requests +- External schemas referenced by the UI5 manifest schema (e.g., Adaptive Card schema) are also fetched and cached as needed + +#### Validation Process + +- Reads the manifest file from the provided absolute path +- Parses the JSON content and extracts the `_version` property +- Fetches the corresponding schema based on the version +- Uses Ajv to validate the manifest against the schema +- Returns a detailed report of validation results, including specific error messages for any violations found + +#### Performance Characteristics + +**Caching Strategy:** +- Schema files are cached in memory after first retrieval +- Cache is shared across multiple validation calls in the same process +- Mutex locks prevent duplicate concurrent downloads of the same schema +- Network requests are only made once per schema version per process lifecycle + +#### Error Handling + +**Input Errors:** +- Non-absolute paths: `InvalidInputError` with clear message +- File not found: `InvalidInputError` indicating the file doesn't exist +- Invalid JSON: `InvalidInputError` with JSON parsing error details +- Missing `_version`: Detailed error with list of supported versions +- Unsupported version: Error message with version requirements and supported versions list + +**Network Errors:** +- Schema fetch failures are caught and reported with helpful context +- The tool provides fallback error messages even if the supported versions list cannot be fetched +- Cached schemas allow continued operation even if the network is unavailable after initial setup + +#### Example Input + +```json +{ + "manifestPath": "/absolute/path/to/project/webapp/manifest.json" +} +``` + +#### Example Output for Invalid Manifest + +```json +{ + "isValid": false, + "errors": [ + { + "keyword": "required", + "instancePath": "", + "schemaPath": "#/required", + "params": { + "missingProperty": "sap.ui" + }, + "message": "must have required property 'sap.ui'" + }, + { + "keyword": "required", + "instancePath": "/sap.app", + "schemaPath": "#/properties/sap.app/required", + "params": { + "missingProperty": "title" + }, + "message": "must have required property 'title'" + } + ] +} +``` + +#### Example Output for Valid Manifest + +```json +{ + "isValid": true, + "errors": [] +} +``` + +#### Requirements + +- **Network Access**: Initial schema fetch requires internet connectivity +- **Manifest Versioning**: The manifest must declare a valid `_version` property + ## Resources ### UI5 Documentation diff --git a/npm-shrinkwrap.json b/npm-shrinkwrap.json index 0822403a..54f91890 100644 --- a/npm-shrinkwrap.json +++ b/npm-shrinkwrap.json @@ -13,6 +13,8 @@ "@ui5/linter": "^1.20.6", "@ui5/logger": "^4.0.2", "@ui5/project": "^4.0.9", + "ajv": "^8.17.1", + "ajv-formats": "^3.0.1", "async-mutex": "^0.5.0", "ejs": "^3.1.10", "execa": "^9.6.1", diff --git a/package.json b/package.json index e8bc4a5e..d3e9ea4c 100644 --- a/package.json +++ b/package.json @@ -79,6 +79,8 @@ "@ui5/linter": "^1.20.6", "@ui5/logger": "^4.0.2", "@ui5/project": "^4.0.9", + "ajv": "^8.17.1", + "ajv-formats": "^3.0.1", "async-mutex": "^0.5.0", "ejs": "^3.1.10", "execa": "^9.6.1", diff --git a/resources/integration_cards_guidelines.md b/resources/integration_cards_guidelines.md index e480aae1..d1c6c99d 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. Use the `run_manifest_validation` tool to do this. - **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 9adcd3c1..8614e513 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"; import registerGetTypescriptConversionGuidelinesTool from "./tools/get_typescript_conversion_guidelines/index.js"; interface Options { @@ -56,6 +57,8 @@ export default function (server: McpServer, context: Context, options: Options) registerCreateIntegrationCardTool(registerTool, context); + registerRunManifestValidationTool(registerTool, context); + registerGetTypescriptConversionGuidelinesTool(registerTool, context); } 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..2701eb48 --- /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(), 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..70b16e4b 100644 --- a/src/utils/ui5Manifest.ts +++ b/src/utils/ui5Manifest.ts @@ -1,15 +1,141 @@ +import {getLogger} from "@ui5/logger"; import {fetchCdn} from "./cdnHelper.js"; +import {Mutex} from "async-mutex"; +import semver from "semver"; -const MAPPING_URL = "https://raw.githubusercontent.com/SAP/ui5-manifest/main/mapping.json"; +const log = getLogger("utils:ui5Manifest"); -async function getUI5toManifestVersionMap() { - const mapping = await fetchCdn(MAPPING_URL); +const schemaCache = new Map(); +const fetchSchemaMutex = new Mutex(); - return mapping as Record; +let UI5ToManifestVersionMapping: Record | null = null; +const MAPPING_URL = "https://raw.githubusercontent.com/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/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; + + 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 = [ + ...new Set( + 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 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..a79d9557 --- /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/UI5/manifest/main/mapping.json") + .resolves({ + "1.79.0": "1.79.0", + }); + + fetchCdnStub.withArgs("https://raw.githubusercontent.com/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/UI5/manifest/main/mapping.json") + .resolves({ + "1.79.0": "1.79.0", + }); + + fetchCdnStub.withArgs("https://raw.githubusercontent.com/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/UI5/manifest/main/mapping.json") + .resolves({ + "1.79.0": "1.79.0", + }); + + fetchCdnStub.withArgs("https://raw.githubusercontent.com/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..3bb04e01 --- /dev/null +++ b/test/lib/tools/run_manifest_validation/runValidation.ts @@ -0,0 +1,395 @@ +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: [], + }); +}); + +test("runValidation handles properties with 'format=uri'", async (t) => { + const {runValidation, getManifestSchemaStub} = t.context; + + // Stub the readFile function to return a manifest with invalid "$schema" URI + t.context.manifestFileContent = JSON.stringify({ + _version: "1.0.0", + $schema: "invalid-uri-format", + }); + + getManifestSchemaStub.resolves({ + type: "object", + properties: { + _version: {type: "string"}, + $schema: { + description: "A URI to the schema", + format: "uri", + type: "string", + }, + }, + required: ["$schema"], + additionalProperties: false, + }); + + const result = await runValidation("/path/to/manifest.json"); + + t.is(result.isValid, false); +}); diff --git a/test/lib/utils/ui5Manifest.ts b/test/lib/utils/ui5Manifest.ts index fece738a..f7a5589c 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/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/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/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/UI5/manifest/main/mapping.json") + .rejects(new Error("Mapping.json error")); + + fetchCdnStub.withArgs("https://raw.githubusercontent.com/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/UI5/manifest/main/mapping.json") + .resolves({ + "1.70.0": "1.70.0", + "1.71.0": "1.71.0", + }); + + fetchCdnStub.withArgs("https://raw.githubusercontent.com/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); +});