Skip to content

Commit bfbdb77

Browse files
815aremarufrasully
andauthored
test: Integration test for fiori MCP using promptfoo (#3705)
* test: very first version very first version * fix: project path project path * fix: dependencies resolution update dependencies resolution update * feat: structure structure * feat: add local test project and setup to use such project during test add local test project and setup to use such project during test * feat: some experiments and custom assert with snapshot some experiments and custom assert with snapshot * fix: snapshot check snapshot check * fix: snapshots snapshots * fix: text text * fix: output output * feat: snapshot segments and loose check and allow pass setup files snapshot segments and loose check and allow pass setup files * test: more tests more tests * test: more tests more tests * test: remove demo approach remove demo approach * feat: cleanup cleanup * test: more scenarios more scenarios * test: adjustments adjustments * feat: delete obsolete files delete obsolete files * test: additional test additional test * feat: change folder structure change folder structure * fix: lint lint * fix: lint lint * fix: snapshot snapshot * fix: cleanup cleanup * changeset changeset * fix: gitignore after folder rename gitignore after folder rename * feat: additional logs additional logs * feat: add v2 project and some tests add v2 project and some tests * feat: model config refresh model config refresh * fix: lock file lock file * test: update config update config * fix: ignore telemetry for mcp server during integration test run ignore telemetry for mcp server during integration test run * feat: remove cost calculation and secure loop to avoid infinitive loops remove cost calculation and secure loop to avoid infinitive loops * lock file lock file * fix: lint lint * fix: review comment review comment * feat: move test project to existing test data folder move test project to existing test data folder * feat: move cap project from unit test to new subfolder move cap project from unit test to new subfolder * fix: review comment review comment * fix: delete copied project before copying delete copied project before copying * feat: rename scripts to suggested namings rename scripts to suggested namings * feat: do not call from pipeline yet do not call from pipeline yet * fix: rename rename * feat: use building assert instead of third party use building assert instead of third party * fix: re-name project * fix: remove project and re-use project * fix: lock file * fix: lint lint * fix: retrigger retrigger * fix: retrigger retrigger * fix: review comment review comment --------- Co-authored-by: Maruf Rasully <[email protected]>
1 parent 5cea995 commit bfbdb77

File tree

79 files changed

+12456
-14095
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

79 files changed

+12456
-14095
lines changed

.changeset/many-flies-dream.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
'@sap-ux/fiori-mcp-server': minor
3+
---
4+
5+
- First integration tests using promptfoo
6+
- Updated input schema for 'execute-functionality' - sometimes input parameters was passed outside of `parameters` property

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,7 @@
7575
"pnpm": {
7676
"overrides": {
7777
"router>path-to-regexp": "0.1.12",
78-
"[email protected]>path-to-regexp": "8.2.0",
78+
"router@^2.0.0>path-to-regexp": "8.2.0",
7979
"@storybook/manager-api>store2": "2.14.4",
8080
"mta-local": "1.0.4",
8181
"axios@<1.12.0": "^1.12.2"
Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,5 @@
11
dist
2-
data
2+
data
3+
reports
4+
test/test-data/copy
5+
test/integration/logs

packages/fiori-mcp-server/package.json

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,10 @@
3434
"lint": "eslint . --ext .ts",
3535
"lint:fix": "eslint . --ext .ts --fix",
3636
"test": "jest --ci --forceExit --detectOpenHandles --colors",
37-
"inspector": "npx @modelcontextprotocol/inspector node dist/index.js"
37+
"inspector": "npx @modelcontextprotocol/inspector node dist/index.js",
38+
"view:integration": "promptfoo view -y",
39+
"test:integration:once": "promptfoo eval --config test/integration/scenarios/promptfooconfig.yaml --max-concurrency 1 --repeat 1 --output reports/integration.txt",
40+
"test:integration:multiple": "npm run test:promptfoo -- --repeat 5"
3841
},
3942
"files": [
4043
"LICENSE",
@@ -58,6 +61,7 @@
5861
"@sap-ux/odata-annotation-core-types": "workspace:*",
5962
"@sap-ux/odata-entity-model": "workspace:*",
6063
"@sap-ux/text-document-utils": "workspace:*",
64+
"@types/diff": "5.0.9",
6165
"@types/json-schema": "7.0.5",
6266
"@types/mem-fs": "1.1.2",
6367
"@types/mem-fs-editor": "7.0.1",
@@ -67,7 +71,12 @@
6771
"@sap-ux/telemetry": "workspace:*",
6872
"i18next": "25.3.0",
6973
"os-name": "4.0.1",
70-
"zod": "4.1.5"
74+
"zod": "4.1.5",
75+
"@sap-ai-sdk/foundation-models": "2.0.0",
76+
"@sap-ai-sdk/langchain": "2.0.0",
77+
"promptfoo": "0.118.6",
78+
"@langchain/mcp-adapters": "0.6.0",
79+
"@langchain/core": "0.3.75"
7180
},
7281
"dependencies": {
7382
"@sap-ux/fiori-docs-embeddings": "*",

packages/fiori-mcp-server/src/types/input.ts

Lines changed: 15 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -38,14 +38,21 @@ export const GetFunctionalityDetailsInputSchema = zod.object({
3838
/**
3939
* Input interface for the 'execute_functionality' functionality
4040
*/
41-
export const ExecuteFunctionalityInputSchema = zod.object({
42-
/** ID or array of IDs of the functionality(ies) to execute */
43-
functionalityId: FunctionalityIdSchema.describe('The ID of the functionality to execute'),
44-
/** Parameters for the functionality execution */
45-
parameters: zod.record(zod.string(), zod.unknown()).describe('Parameters for the functionality execution'),
46-
/** Path to the Fiori application */
47-
appPath: zod.string().describe('Path to the Fiori application. Path should be an absolute path.')
48-
});
41+
export const ExecuteFunctionalityInputSchema = zod
42+
.object({
43+
/** ID or array of IDs of the functionality(ies) to execute */
44+
functionalityId: FunctionalityIdSchema.describe('The ID of the functionality to execute'),
45+
/** Parameters for the functionality execution */
46+
parameters: zod.record(zod.string(), zod.unknown()).describe('Parameters for the functionality execution'),
47+
/** Path to the Fiori application */
48+
appPath: zod.string().describe('Path to the Fiori application. Path should be an absolute path.')
49+
})
50+
.describe(
51+
'Input object for executing a functionality. ' +
52+
'Only three top-level properties are allowed: "functionalityId", "parameters", and "appPath". ' +
53+
'All other dynamic or functionality-specific inputs must be included inside the "parameters" object. ' +
54+
'Do not place any additional fields at the root level.'
55+
);
4956

5057
export const DocSearchInputSchema = zod.object({
5158
query: zod
Lines changed: 272 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,272 @@
1+
import fs from 'fs/promises';
2+
import { existsSync } from 'fs';
3+
import { basename, join, dirname } from 'path';
4+
import type { AssertionValueFunctionContext, AssertionValueFunctionResult } from 'promptfoo';
5+
import { FOLDER_PATHS } from '../types';
6+
import assert from 'node:assert';
7+
8+
interface SnapshotData {
9+
content: {
10+
snapshot: string;
11+
source: string;
12+
};
13+
created: boolean;
14+
}
15+
16+
interface SnapshotSegmentConfig {
17+
path: Array<string>;
18+
mode: string;
19+
}
20+
21+
interface SnapshotConfiguration {
22+
snapshot: string;
23+
file: string;
24+
segments?: SnapshotSegmentConfig[];
25+
}
26+
27+
/**
28+
* Compares the current test output against a stored snapshot to verify consistency.
29+
* Loads the snapshot data based on the provided test context and configuration,
30+
* validates it against the expected snapshot, and returns an assertion result.
31+
*
32+
* @param _output The string output generated by the test or process under validation.
33+
* @param context The assertion context, containing test variables and optional configuration.
34+
* @returns Result of assertion for promptfoo.
35+
*/
36+
export async function validate(
37+
_output: string,
38+
context: AssertionValueFunctionContext
39+
): Promise<AssertionValueFunctionResult> {
40+
let reason = 'Unknown';
41+
let pass = false;
42+
const appPath = getAppPath(context.vars);
43+
const config = context.config ? getConfiguration(context.config) : undefined;
44+
if (appPath && config) {
45+
try {
46+
const snapshotData = await getSnapshotData(appPath, config.snapshot, config.file);
47+
const compareResult = validateSnapshot(snapshotData, config);
48+
pass = !compareResult;
49+
if (!pass) {
50+
console.log(`Snapshot mismatch for ${config.file}:\n${compareResult}`);
51+
}
52+
reason = pass ? 'Snapshot file matches' : `Snapshot file does not match: ${compareResult}`;
53+
} catch (e) {
54+
return {
55+
pass: false,
56+
score: 0,
57+
reason: e.message
58+
};
59+
}
60+
}
61+
62+
return {
63+
pass,
64+
score: pass ? 1 : 0,
65+
reason
66+
};
67+
}
68+
69+
/**
70+
* Retrieves the application path from a set of provided variables.
71+
*
72+
* @param vars Promptfoo context variables containing potential APP_PATH value.
73+
* @returns The application path string if available and valid in passed variables, otherwise undefined.
74+
*/
75+
function getAppPath(vars: Record<string, string | object>): string | undefined {
76+
return vars.APP_PATH && typeof vars.APP_PATH === 'string' ? vars.APP_PATH : undefined;
77+
}
78+
79+
/**
80+
* Parses and validates a raw configuration object into a normalized `SnapshotConfiguration`.
81+
*
82+
* @param config A raw configuration record, typically loaded from a test context.
83+
* @returns A normalized `SnapshotConfiguration` object, or `undefined` if validation fails.
84+
*/
85+
function getConfiguration(config: Record<string, unknown>): SnapshotConfiguration | undefined {
86+
if (
87+
'file' in config &&
88+
typeof config.file === 'string' &&
89+
'snapshot' in config &&
90+
typeof config.snapshot === 'string'
91+
) {
92+
let segments: SnapshotSegmentConfig[] | undefined;
93+
if ('segments' in config && Array.isArray(config.segments)) {
94+
segments = config.segments.filter((segment) => typeof segment === 'object' && 'path' in segment);
95+
}
96+
return {
97+
snapshot: config.snapshot,
98+
file: config.file,
99+
segments
100+
};
101+
}
102+
}
103+
104+
/**
105+
* Ensures the existence of a snapshot file for the given target and returns its data.
106+
*
107+
* @param projectPath The absolute path to the current project.
108+
* @param key A key identifying the snapshot set or namespace.
109+
* @param targetPath The relative path to the file being compared.
110+
* @returns A `SnapshotData` object containing both source and snapshot contents.
111+
*/
112+
async function getSnapshotData(projectPath: string, key: string, targetPath: string): Promise<SnapshotData> {
113+
let snapshotFolder = join(FOLDER_PATHS.snapshots, key);
114+
const relativeFolder = dirname(join(targetPath));
115+
if (relativeFolder) {
116+
snapshotFolder = join(snapshotFolder, relativeFolder);
117+
}
118+
// Make sure snapshot folder exists
119+
if (!existsSync(snapshotFolder)) {
120+
await fs.mkdir(snapshotFolder, { recursive: true });
121+
}
122+
// Check target file
123+
const filePath = join(projectPath, targetPath);
124+
if (!existsSync(filePath)) {
125+
throw new Error(`${filePath} does not exists`);
126+
}
127+
const fileName = basename(filePath);
128+
const snapshotFile = join(snapshotFolder, fileName);
129+
let created = false;
130+
if (!existsSync(snapshotFile)) {
131+
// Write snapshot
132+
await fs.copyFile(filePath, snapshotFile);
133+
created = true;
134+
}
135+
const sourceContent = await fs.readFile(filePath, 'utf8');
136+
const snapshotContent = await fs.readFile(snapshotFile, 'utf8');
137+
return {
138+
content: {
139+
snapshot: snapshotContent,
140+
source: sourceContent
141+
},
142+
created
143+
};
144+
}
145+
146+
/**
147+
* Validates that the provided source content matches its corresponding snapshot,
148+
* optionally comparing specific JSON segments.
149+
*
150+
* @param snapshotData The snapshot and source contents to compare.
151+
* @param config The snapshot configuration specifying comparison behavior.
152+
* @returns A string describing the first detected difference or `undefined` if the snapshot matches the source.
153+
*/
154+
function validateSnapshot(snapshotData: SnapshotData, config: SnapshotConfiguration): string | undefined {
155+
let compareResult: string | undefined;
156+
if (config.segments && config.file.endsWith('.json')) {
157+
const actual = JSON.parse(snapshotData.content.source);
158+
const snapshot = JSON.parse(snapshotData.content.snapshot);
159+
for (const segement of config.segments) {
160+
const actualSegment = getByPath(actual, segement.path);
161+
const snapshotSegment = getByPath(snapshot, segement.path);
162+
if (
163+
actualSegment !== null &&
164+
snapshotSegment !== null &&
165+
typeof actualSegment === 'object' &&
166+
typeof snapshotSegment === 'object'
167+
) {
168+
if (segement.mode === 'contains') {
169+
compareResult = deepContains(snapshotSegment, actualSegment);
170+
} else {
171+
compareResult = compare(actualSegment, snapshotSegment);
172+
}
173+
} else {
174+
compareResult =
175+
actualSegment === snapshotSegment ? undefined : `Value differs for ${segement.path.join('/')}`;
176+
}
177+
if (compareResult !== undefined) {
178+
break;
179+
}
180+
}
181+
} else {
182+
compareResult = config.file.endsWith('.json')
183+
? compare(JSON.parse(snapshotData.content.source), JSON.parse(snapshotData.content.snapshot))
184+
: compare(snapshotData.content.source, snapshotData.content.snapshot);
185+
}
186+
return compareResult;
187+
}
188+
189+
/**
190+
* Retrieves a nested value from an object or array using a sequence of keys.
191+
*
192+
* @param obj The object or array to traverse.
193+
* @param path An array of keys or indices representing the access path.
194+
* @returns The value found at the given path, or `undefined` if any part of the path is invalid.
195+
*/
196+
function getByPath(obj: unknown, path: (string | number)[]): unknown {
197+
if (!Array.isArray(path)) {
198+
return undefined;
199+
}
200+
201+
let current: unknown = obj;
202+
203+
for (let i = 0; i < path.length; i++) {
204+
const key = path[i];
205+
// Ensure current is an object or array before trying to access properties
206+
if (
207+
current === null ||
208+
typeof current !== 'object' ||
209+
!(key in (current as Record<string | number, unknown>))
210+
) {
211+
return undefined;
212+
}
213+
214+
current = (current as Record<string | number, unknown>)[key];
215+
}
216+
217+
return current;
218+
}
219+
220+
/**
221+
* Compares two values (objects or strings) for deep equality.
222+
* Uses Node's built-in `assert.deepStrictEqual()` internally.
223+
* If the values differ, it returns a human-readable diff string.
224+
* If the values are deeply equal, it returns `undefined`.
225+
*
226+
* @param {string | object} value1 - The first object or string to compare.
227+
* @param {string | object} value2 - The second object or string to compare.
228+
* @returns {string | undefined} A string describing the differences if values differ,
229+
* or `undefined` if they are identical.
230+
*/
231+
function compare(value1: string | object, value2: string | object): string | undefined {
232+
try {
233+
assert.deepStrictEqual(value1, value2);
234+
} catch (e) {
235+
return e.message;
236+
}
237+
}
238+
239+
/**
240+
* Recursively checks whether all properties and values of the `expected` object
241+
* are contained within the `actual` object.
242+
*
243+
* @param expected The reference value or object to compare against.
244+
* @param actual The object or value being tested for containment.
245+
* @param path (Internal) The current object path used for detailed mismatch reporting.
246+
* @returns A descriptive error string indicating the first mismatch or missing key,
247+
* or `undefined` if `actual` fully contains `expected`.
248+
*/
249+
function deepContains(expected: unknown, actual: unknown, path = ''): string | undefined {
250+
// Handle primitive values (and null)
251+
if (typeof expected !== 'object' || expected === null || typeof actual !== 'object' || actual === null) {
252+
return Object.is(expected, actual) ? undefined : `Mismatch at ${path}: expected ${expected}, got ${actual}`;
253+
}
254+
255+
// At this point both are non-null objects
256+
const expectedObj = expected as Record<string, unknown>;
257+
const actualObj = actual as Record<string, unknown>;
258+
259+
for (const key of Object.keys(expectedObj)) {
260+
if (!(key in actualObj)) {
261+
return `Missing key at ${path}.${key}`;
262+
}
263+
264+
const result = deepContains(expectedObj[key], actualObj[key], `${path}.${key}`);
265+
if (result) {
266+
return result;
267+
}
268+
}
269+
270+
// Ignore extra keys in `actual`
271+
return undefined;
272+
}

0 commit comments

Comments
 (0)