Skip to content

Commit 9ee721e

Browse files
committed
test(runValidation): Add tests
1 parent e4d8966 commit 9ee721e

File tree

6 files changed

+314
-30
lines changed

6 files changed

+314
-30
lines changed

resources/integration_cards_guidelines.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@
4242
## 2. Validation
4343
- **ALWAYS** ensure that `manifest.json` file is valid JSON.
4444
- **ALWAYS** ensure that in `manifest.json` file the property `sap.app/type` is set to `"card"`.
45+
- **ALWAYS** validate the `manifest.json` against the UI5 Manifest schema. You must do it using the `run_manifest_validation` tool.
4546
- **ALWAYS** avoid deprecated properties in `manifest.json` and other places.
4647
- **NEVER** treat Integration Cards project as UI5 project, except for cards of type "Component".
4748

src/tools/run_manifest_validation/runValidation.ts

Lines changed: 20 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,12 @@ import {readFile} from "fs/promises";
55
import {getLogger} from "@ui5/logger";
66
import {InvalidInputError} from "../../utils.js";
77
import {getManifestSchema} from "../../utils/ui5Manifest.js";
8+
import {Mutex} from "async-mutex";
89

910
const log = getLogger("tools:run_manifest_validation:runValidation");
10-
const schemaCache = new Map<string, Promise<object>>();
11+
const schemaCache = new Map<string, AnySchemaObject>();
12+
const fetchSchemaMutex = new Mutex();
1113

12-
// Configuration constants
1314
const AJV_SCHEMA_PATHS = {
1415
draft06: "node_modules/ajv/dist/refs/json-schema-draft-06.json",
1516
draft07: "node_modules/ajv/dist/refs/json-schema-draft-07.json",
@@ -22,46 +23,35 @@ async function createUI5ManifestValidateFunction(ui5Schema: object) {
2223
strict: false, // Allow additional properties that are not in schema
2324
unicodeRegExp: false,
2425
loadSchema: async (uri) => {
25-
// Check cache first to prevent infinite loops
26+
const release = await fetchSchemaMutex.acquire();
27+
2628
if (schemaCache.has(uri)) {
2729
log.info(`Loading cached schema: ${uri}`);
28-
29-
try {
30-
const schema = await schemaCache.get(uri)!;
31-
return schema;
32-
} catch {
33-
schemaCache.delete(uri);
34-
}
30+
return schemaCache.get(uri)!;
3531
}
3632

37-
log.info(`Loading external schema: ${uri}`);
38-
let fetchSchema: Promise<object>;
39-
4033
try {
41-
if (uri.includes("adaptive-card.json")) {
42-
// Special handling for Adaptive Card schema to fix unsupported "id" property
43-
// According to the JSON Schema spec Draft 06 (used by Adaptive Card schema),
44-
// "$id" should be used instead of "id"
45-
fetchSchema = fetchCdn(uri)
46-
.then((response) => {
47-
if ("id" in response && typeof response.id === "string") {
48-
const typedResponse = response as Record<string, unknown>;
49-
typedResponse.$id = response.id;
50-
delete typedResponse.id;
51-
}
52-
return response;
53-
});
54-
} else {
55-
fetchSchema = fetchCdn(uri);
34+
log.info(`Loading external schema: ${uri}`);
35+
const schema = await fetchCdn(uri) as AnySchemaObject;
36+
37+
// Special handling for Adaptive Card schema to fix unsupported "id" property
38+
// According to the JSON Schema spec Draft 06 (used by Adaptive Card schema),
39+
// "$id" should be used instead of "id"
40+
if (uri.includes("adaptive-card.json") && typeof schema.id === "string") {
41+
schema.$id = schema.id;
42+
delete schema.id;
5643
}
5744

58-
schemaCache.set(uri, fetchSchema);
59-
return fetchSchema;
45+
schemaCache.set(uri, schema);
46+
47+
return schema;
6048
} catch (error) {
6149
log.warn(`Failed to load external schema ${uri}:` +
6250
`${error instanceof Error ? error.message : String(error)}`);
6351

6452
throw error;
53+
} finally {
54+
release();
6555
}
6656
},
6757
});
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
{
2+
"$schema": "https://json-schema.org/draft/2020-12/schema",
3+
"type": "object",
4+
"required": ["_version", "sap.app"],
5+
"properties": {
6+
"_version": {
7+
"type": "string"
8+
},
9+
"sap.app": {
10+
"type": "object",
11+
"required": ["id", "type", "applicationVersion"],
12+
"properties": {
13+
"id": {
14+
"type": "string"
15+
},
16+
"type": {
17+
"type": "string"
18+
},
19+
"title": {
20+
"type": "string"
21+
},
22+
"description": {
23+
"type": "string"
24+
},
25+
"applicationVersion": {
26+
"type": "object",
27+
"required": ["version"],
28+
"properties": {
29+
"version": {
30+
"type": "string"
31+
}
32+
}
33+
},
34+
"dataSources": {
35+
"type": "object"
36+
}
37+
}
38+
},
39+
"sap.ui": {
40+
"type": "object"
41+
},
42+
"sap.ui5": {
43+
"type": "object"
44+
}
45+
}
46+
}
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
{
2+
"_version": "1.59.0",
3+
"sap.app": {
4+
"id": "com.example.app",
5+
"type": "application",
6+
"applicationVersion": {
7+
"version": "1.0.0"
8+
}
9+
},
10+
"sap.ui": {
11+
"technology": "UI5",
12+
"deviceTypes": {
13+
"desktop": true,
14+
"tablet": true,
15+
"phone": true
16+
}
17+
},
18+
"sap.ui5": {
19+
"dependencies": {
20+
"minUI5Version": "1.120.0",
21+
"libs": {
22+
"sap.m": {}
23+
}
24+
}
25+
}
26+
}
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import anyTest, {TestFn} from "ava";
2+
import * as sinon from "sinon";
3+
import esmock from "esmock";
4+
import {readFile} from "fs/promises";
5+
import path from "path";
6+
import {fileURLToPath} from "url";
7+
8+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
9+
const fixturesPath = path.join(__dirname, "..", "..", "..", "fixtures", "manifest_validation");
10+
11+
const test = anyTest as TestFn<{
12+
sinon: sinon.SinonSandbox;
13+
runValidation: typeof import("../../../../src/tools/run_manifest_validation/runValidation.js").default;
14+
}>;
15+
16+
test.beforeEach(async (t) => {
17+
t.context.sinon = sinon.createSandbox();
18+
19+
const schemaFixture = await readFile(path.join(fixturesPath, "schema.json"), "utf-8");
20+
const getManifestSchemaStub = t.context.sinon.stub().resolves(JSON.parse(schemaFixture));
21+
22+
// Import the runValidation function
23+
t.context.runValidation = (await esmock(
24+
"../../../../src/tools/run_manifest_validation/runValidation.js", {
25+
"../../../../src/utils/ui5Manifest.js": {
26+
getManifestSchema: getManifestSchemaStub,
27+
},
28+
}
29+
)).default;
30+
});
31+
32+
test.afterEach.always((t) => {
33+
t.context.sinon.restore();
34+
});
35+
36+
test("runValidation successfully validates valid manifest", async (t) => {
37+
const {runValidation} = t.context;
38+
39+
const result = await runValidation(path.join(fixturesPath, "valid-manifest.json"));
40+
41+
t.deepEqual(result, {
42+
isValid: true,
43+
errors: [],
44+
});
45+
});
Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,176 @@
1+
import anyTest, {TestFn} from "ava";
2+
import * as sinon from "sinon";
3+
import esmock from "esmock";
4+
import {readFile} from "fs/promises";
5+
6+
const test = anyTest as TestFn<{
7+
sinon: sinon.SinonSandbox;
8+
readFileStub: sinon.SinonStub;
9+
manifestFileContent: string;
10+
getManifestSchemaStub: sinon.SinonStub;
11+
runValidation: typeof import("../../../../src/tools/run_manifest_validation/runValidation.js").default;
12+
}>;
13+
14+
test.beforeEach(async (t) => {
15+
t.context.sinon = sinon.createSandbox();
16+
t.context.manifestFileContent = "";
17+
18+
// Create a stub that only intercepts specific manifest paths, otherwise calls real readFile
19+
t.context.readFileStub = t.context.sinon.stub().callsFake(async (
20+
path: string,
21+
encoding?: BufferEncoding | null
22+
) => {
23+
// Only handle specific manifest paths that we explicitly stub
24+
if (path === "/path/to/manifest.json") {
25+
// These will be handled by withArgs() stubs below
26+
return t.context.manifestFileContent;
27+
}
28+
// For all other files (including AJV schema files), call the real readFile
29+
return readFile(path, encoding ?? "utf-8");
30+
});
31+
32+
t.context.getManifestSchemaStub = t.context.sinon.stub();
33+
34+
// Import the runValidation function
35+
t.context.runValidation = (await esmock(
36+
"../../../../src/tools/run_manifest_validation/runValidation.js", {
37+
"fs/promises": {
38+
readFile: t.context.readFileStub,
39+
},
40+
"../../../../src/utils/ui5Manifest.js": {
41+
getManifestSchema: t.context.getManifestSchemaStub,
42+
},
43+
}
44+
)).default;
45+
});
46+
47+
test.afterEach.always((t) => {
48+
t.context.sinon.restore();
49+
});
50+
51+
test("runValidation successfully validates valid manifest", async (t) => {
52+
const {runValidation, getManifestSchemaStub} = t.context;
53+
54+
// Stub the readFile function to return a valid manifest
55+
const validManifest = {
56+
"sap.app": {
57+
id: "my.app.id",
58+
type: "application",
59+
},
60+
};
61+
t.context.manifestFileContent = JSON.stringify(validManifest);
62+
63+
getManifestSchemaStub.resolves({
64+
type: "object",
65+
properties: {
66+
"sap.app": {
67+
type: "object",
68+
properties: {
69+
id: {type: "string"},
70+
type: {type: "string"},
71+
},
72+
required: ["id", "type"],
73+
},
74+
},
75+
required: ["sap.app"],
76+
});
77+
78+
const result = await runValidation("/path/to/manifest.json");
79+
80+
t.deepEqual(result, {
81+
isValid: true,
82+
errors: [],
83+
});
84+
});
85+
86+
test("runValidation successfully validates invalid manifest", async (t) => {
87+
const {runValidation, getManifestSchemaStub} = t.context;
88+
89+
// Stub the readFile function to return an invalid manifest
90+
const invalidManifest = {
91+
"sap.app": {
92+
id: "my.app.id",
93+
// Missing required field "type"
94+
},
95+
};
96+
t.context.manifestFileContent = JSON.stringify(invalidManifest);
97+
98+
getManifestSchemaStub.resolves({
99+
type: "object",
100+
properties: {
101+
"sap.app": {
102+
type: "object",
103+
properties: {
104+
id: {type: "string"},
105+
type: {type: "string"},
106+
},
107+
required: ["id", "type"],
108+
},
109+
},
110+
required: ["sap.app"],
111+
additionalProperties: false,
112+
});
113+
114+
const result = await runValidation("/path/to/manifest.json");
115+
116+
t.deepEqual(result, {
117+
isValid: false,
118+
errors: [
119+
{
120+
params: {missingProperty: "type"},
121+
keyword: "required",
122+
instancePath: "/sap.app",
123+
schemaPath: "#/properties/sap.app/required",
124+
message: "must have required property 'type'",
125+
propertyName: undefined,
126+
schema: undefined,
127+
parentSchema: undefined,
128+
data: undefined,
129+
},
130+
],
131+
});
132+
});
133+
134+
test("runValidation throws error when manifest file path is not correct", async (t) => {
135+
const {runValidation, readFileStub} = t.context;
136+
137+
// Stub the readFile function to throw an error
138+
readFileStub.rejects(new Error("File not found"));
139+
140+
await t.throwsAsync(async () => {
141+
const result = await runValidation("/nonexistent/path");
142+
return result;
143+
}, {
144+
instanceOf: Error,
145+
message: /Failed to read manifest file at .+: .+/,
146+
});
147+
});
148+
149+
test("runValidation throws error when manifest file content is invalid JSON", async (t) => {
150+
const {runValidation} = t.context;
151+
152+
t.context.manifestFileContent = "Invalid JSON Content";
153+
154+
await t.throwsAsync(async () => {
155+
const result = await runValidation("/path/to/manifest.json");
156+
return result;
157+
}, {
158+
instanceOf: Error,
159+
message: /Failed to parse manifest file at .+ as JSON: .+/,
160+
});
161+
});
162+
163+
test("runValidation throws error when schema validation function cannot be compiled", async (t) => {
164+
const {runValidation, getManifestSchemaStub} = t.context;
165+
166+
t.context.manifestFileContent = JSON.stringify({});
167+
getManifestSchemaStub.resolves(null); // Simulate invalid schema
168+
169+
await t.throwsAsync(async () => {
170+
const result = await runValidation("/path/to/manifest.json");
171+
return result;
172+
}, {
173+
instanceOf: Error,
174+
message: /Failed to create UI5 manifest validate function: .+/,
175+
});
176+
});

0 commit comments

Comments
 (0)