generated from SAP/repository-template
-
Notifications
You must be signed in to change notification settings - Fork 54
test: Integration test for fiori MCP using promptfoo #3705
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
62 commits
Select commit
Hold shift + click to select a range
a3778b6
test: very first version
815are d0e3de6
fix: project path
815are ccb9083
fix: dependencies resolution update
815are 45a94d2
feat: structure
815are 029d64a
feat: add local test project and setup to use such project during test
815are 5b75239
feat: some experiments and custom assert with snapshot
815are 1da2860
fix: snapshot check
815are b45df53
fix: snapshots
815are ec63733
fix: text
815are 38d6a83
fix: output
815are d807f8e
feat: snapshot segments and loose check and allow pass setup files
815are 872f9dd
test: more tests
815are 102eab8
test: more tests
815are 2518508
test: remove demo approach
815are 5490baa
feat: cleanup
815are 1415a13
test: more scenarios
815are 9210c60
test: adjustments
815are 6954ec2
feat: delete obsolete files
815are 5e401f6
test: additional test
815are 2ef2b6a
Merge branch 'main' into feat/mcpServerPromptFooIntTest
815are 79d723c
feat: change folder structure
815are cb8039b
fix: lint
815are 9355a3a
fix: lint
815are 741f5d6
fix: snapshot
815are 87a2284
Merge branch 'main' into feat/mcpServerPromptFooIntTest
815are d9c9b91
fix: cleanup
815are 9089898
Merge branch 'feat/mcpServerPromptFooIntTest' of https://github.com/S…
815are e48b174
changeset
815are 852635f
fix: gitignore after folder rename
815are a61fd40
Merge remote-tracking branch 'origin/main' into feat/mcpServerPromptF…
815are 301a21d
feat: additional logs
815are a924a32
feat: add v2 project and some tests
815are 294d1a5
Merge remote-tracking branch 'origin/main' into feat/mcpServerPromptF…
815are ea6ec47
feat: model config refresh
815are ad35c64
Merge branch 'main' into feat/mcpServerPromptFooIntTest
815are bd3d974
fix: lock file
815are 1ca3c03
Merge branch 'feat/mcpServerPromptFooIntTest' of https://github.com/S…
815are 87600e4
test: update config
815are 8f4e624
fix: ignore telemetry for mcp server during integration test run
815are b0451cf
Merge branch 'main' into feat/mcpServerPromptFooIntTest
815are 706bd55
Merge remote-tracking branch 'origin' into feat/mcpServerPromptFooInt…
815are 3c47420
feat: remove cost calculation and secure loop to avoid infinitive loops
815are 6c78fac
lock file
815are 374e4c4
fix: lint
815are 5765f64
fix: review comment
815are 5ad6523
feat: move test project to existing test data folder
815are 9de546a
feat: move cap project from unit test to new subfolder
815are 0fac7f0
fix: review comment
815are f3a2e73
fix: delete copied project before copying
815are 309f7d9
feat: rename scripts to suggested namings
815are d355cde
feat: do not call from pipeline yet
815are 2f3e2d7
fix: rename
815are fb3f8a4
feat: use building assert instead of third party
815are 6685b4d
fix: re-name project
marufrasully e58a6f9
fix: remove project and re-use project
marufrasully 71d4d69
Merge remote-tracking branch origin/main into feat/mcpServerPromptFoo…
marufrasully 0a849db
fix: lock file
marufrasully c4d0d52
fix: lint
815are 253a697
Merge branch 'main' into feat/mcpServerPromptFooIntTest
marufrasully 0793e8a
fix: retrigger
815are 8680493
fix: retrigger
815are 8556d73
fix: review comment
815are File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,6 @@ | ||
| --- | ||
| '@sap-ux/fiori-mcp-server': minor | ||
| --- | ||
|
|
||
| - First integration tests using promptfoo | ||
| - Updated input schema for 'execute-functionality' - sometimes input parameters was passed outside of `parameters` property |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -75,7 +75,7 @@ | |
| "pnpm": { | ||
| "overrides": { | ||
| "router>path-to-regexp": "0.1.12", | ||
| "[email protected]>path-to-regexp": "8.2.0", | ||
| "router@^2.0.0>path-to-regexp": "8.2.0", | ||
| "@storybook/manager-api>store2": "2.14.4", | ||
| "mta-local": "1.0.4", | ||
| "axios@<1.12.0": "^1.12.2" | ||
|
|
||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,2 +1,5 @@ | ||
| dist | ||
| data | ||
| data | ||
| reports | ||
| test/test-data/copy | ||
| test/integration/logs | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
272 changes: 272 additions & 0 deletions
272
packages/fiori-mcp-server/test/integration/assertions/snapshots.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,272 @@ | ||
| import fs from 'fs/promises'; | ||
| import { existsSync } from 'fs'; | ||
| import { basename, join, dirname } from 'path'; | ||
| import type { AssertionValueFunctionContext, AssertionValueFunctionResult } from 'promptfoo'; | ||
| import { FOLDER_PATHS } from '../types'; | ||
| import assert from 'node:assert'; | ||
|
|
||
| interface SnapshotData { | ||
| content: { | ||
| snapshot: string; | ||
| source: string; | ||
| }; | ||
| created: boolean; | ||
| } | ||
|
|
||
| interface SnapshotSegmentConfig { | ||
| path: Array<string>; | ||
| mode: string; | ||
| } | ||
|
|
||
| interface SnapshotConfiguration { | ||
| snapshot: string; | ||
| file: string; | ||
| segments?: SnapshotSegmentConfig[]; | ||
| } | ||
|
|
||
| /** | ||
| * Compares the current test output against a stored snapshot to verify consistency. | ||
| * Loads the snapshot data based on the provided test context and configuration, | ||
| * validates it against the expected snapshot, and returns an assertion result. | ||
| * | ||
| * @param _output The string output generated by the test or process under validation. | ||
| * @param context The assertion context, containing test variables and optional configuration. | ||
| * @returns Result of assertion for promptfoo. | ||
| */ | ||
| export async function validate( | ||
| _output: string, | ||
| context: AssertionValueFunctionContext | ||
| ): Promise<AssertionValueFunctionResult> { | ||
| let reason = 'Unknown'; | ||
| let pass = false; | ||
| const appPath = getAppPath(context.vars); | ||
| const config = context.config ? getConfiguration(context.config) : undefined; | ||
| if (appPath && config) { | ||
| try { | ||
| const snapshotData = await getSnapshotData(appPath, config.snapshot, config.file); | ||
| const compareResult = validateSnapshot(snapshotData, config); | ||
| pass = !compareResult; | ||
| if (!pass) { | ||
| console.log(`Snapshot mismatch for ${config.file}:\n${compareResult}`); | ||
| } | ||
| reason = pass ? 'Snapshot file matches' : `Snapshot file does not match: ${compareResult}`; | ||
| } catch (e) { | ||
| return { | ||
| pass: false, | ||
| score: 0, | ||
| reason: e.message | ||
| }; | ||
| } | ||
| } | ||
|
|
||
| return { | ||
| pass, | ||
| score: pass ? 1 : 0, | ||
| reason | ||
| }; | ||
| } | ||
|
|
||
| /** | ||
| * Retrieves the application path from a set of provided variables. | ||
| * | ||
| * @param vars Promptfoo context variables containing potential APP_PATH value. | ||
| * @returns The application path string if available and valid in passed variables, otherwise undefined. | ||
| */ | ||
| function getAppPath(vars: Record<string, string | object>): string | undefined { | ||
| return vars.APP_PATH && typeof vars.APP_PATH === 'string' ? vars.APP_PATH : undefined; | ||
| } | ||
|
|
||
| /** | ||
| * Parses and validates a raw configuration object into a normalized `SnapshotConfiguration`. | ||
| * | ||
| * @param config A raw configuration record, typically loaded from a test context. | ||
| * @returns A normalized `SnapshotConfiguration` object, or `undefined` if validation fails. | ||
| */ | ||
| function getConfiguration(config: Record<string, unknown>): SnapshotConfiguration | undefined { | ||
| if ( | ||
| 'file' in config && | ||
| typeof config.file === 'string' && | ||
| 'snapshot' in config && | ||
| typeof config.snapshot === 'string' | ||
| ) { | ||
| let segments: SnapshotSegmentConfig[] | undefined; | ||
| if ('segments' in config && Array.isArray(config.segments)) { | ||
| segments = config.segments.filter((segment) => typeof segment === 'object' && 'path' in segment); | ||
| } | ||
| return { | ||
| snapshot: config.snapshot, | ||
| file: config.file, | ||
| segments | ||
| }; | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * Ensures the existence of a snapshot file for the given target and returns its data. | ||
| * | ||
| * @param projectPath The absolute path to the current project. | ||
| * @param key A key identifying the snapshot set or namespace. | ||
| * @param targetPath The relative path to the file being compared. | ||
| * @returns A `SnapshotData` object containing both source and snapshot contents. | ||
| */ | ||
| async function getSnapshotData(projectPath: string, key: string, targetPath: string): Promise<SnapshotData> { | ||
| let snapshotFolder = join(FOLDER_PATHS.snapshots, key); | ||
| const relativeFolder = dirname(join(targetPath)); | ||
| if (relativeFolder) { | ||
| snapshotFolder = join(snapshotFolder, relativeFolder); | ||
| } | ||
| // Make sure snapshot folder exists | ||
| if (!existsSync(snapshotFolder)) { | ||
| await fs.mkdir(snapshotFolder, { recursive: true }); | ||
| } | ||
| // Check target file | ||
| const filePath = join(projectPath, targetPath); | ||
| if (!existsSync(filePath)) { | ||
| throw new Error(`${filePath} does not exists`); | ||
| } | ||
| const fileName = basename(filePath); | ||
| const snapshotFile = join(snapshotFolder, fileName); | ||
| let created = false; | ||
| if (!existsSync(snapshotFile)) { | ||
| // Write snapshot | ||
| await fs.copyFile(filePath, snapshotFile); | ||
| created = true; | ||
| } | ||
| const sourceContent = await fs.readFile(filePath, 'utf8'); | ||
| const snapshotContent = await fs.readFile(snapshotFile, 'utf8'); | ||
| return { | ||
| content: { | ||
| snapshot: snapshotContent, | ||
| source: sourceContent | ||
| }, | ||
| created | ||
| }; | ||
| } | ||
|
|
||
| /** | ||
| * Validates that the provided source content matches its corresponding snapshot, | ||
| * optionally comparing specific JSON segments. | ||
| * | ||
| * @param snapshotData The snapshot and source contents to compare. | ||
| * @param config The snapshot configuration specifying comparison behavior. | ||
| * @returns A string describing the first detected difference or `undefined` if the snapshot matches the source. | ||
| */ | ||
| function validateSnapshot(snapshotData: SnapshotData, config: SnapshotConfiguration): string | undefined { | ||
| let compareResult: string | undefined; | ||
| if (config.segments && config.file.endsWith('.json')) { | ||
| const actual = JSON.parse(snapshotData.content.source); | ||
| const snapshot = JSON.parse(snapshotData.content.snapshot); | ||
| for (const segement of config.segments) { | ||
| const actualSegment = getByPath(actual, segement.path); | ||
| const snapshotSegment = getByPath(snapshot, segement.path); | ||
| if ( | ||
| actualSegment !== null && | ||
| snapshotSegment !== null && | ||
| typeof actualSegment === 'object' && | ||
| typeof snapshotSegment === 'object' | ||
| ) { | ||
| if (segement.mode === 'contains') { | ||
| compareResult = deepContains(snapshotSegment, actualSegment); | ||
| } else { | ||
| compareResult = compare(actualSegment, snapshotSegment); | ||
| } | ||
| } else { | ||
| compareResult = | ||
| actualSegment === snapshotSegment ? undefined : `Value differs for ${segement.path.join('/')}`; | ||
| } | ||
| if (compareResult !== undefined) { | ||
| break; | ||
| } | ||
| } | ||
| } else { | ||
| compareResult = config.file.endsWith('.json') | ||
| ? compare(JSON.parse(snapshotData.content.source), JSON.parse(snapshotData.content.snapshot)) | ||
| : compare(snapshotData.content.source, snapshotData.content.snapshot); | ||
| } | ||
| return compareResult; | ||
| } | ||
|
|
||
| /** | ||
| * Retrieves a nested value from an object or array using a sequence of keys. | ||
| * | ||
| * @param obj The object or array to traverse. | ||
| * @param path An array of keys or indices representing the access path. | ||
| * @returns The value found at the given path, or `undefined` if any part of the path is invalid. | ||
| */ | ||
| function getByPath(obj: unknown, path: (string | number)[]): unknown { | ||
| if (!Array.isArray(path)) { | ||
| return undefined; | ||
| } | ||
|
|
||
| let current: unknown = obj; | ||
|
|
||
| for (let i = 0; i < path.length; i++) { | ||
| const key = path[i]; | ||
| // Ensure current is an object or array before trying to access properties | ||
| if ( | ||
| current === null || | ||
| typeof current !== 'object' || | ||
| !(key in (current as Record<string | number, unknown>)) | ||
| ) { | ||
| return undefined; | ||
| } | ||
|
|
||
| current = (current as Record<string | number, unknown>)[key]; | ||
| } | ||
|
|
||
| return current; | ||
| } | ||
|
|
||
| /** | ||
| * Compares two values (objects or strings) for deep equality. | ||
| * Uses Node's built-in `assert.deepStrictEqual()` internally. | ||
| * If the values differ, it returns a human-readable diff string. | ||
| * If the values are deeply equal, it returns `undefined`. | ||
| * | ||
| * @param {string | object} value1 - The first object or string to compare. | ||
| * @param {string | object} value2 - The second object or string to compare. | ||
| * @returns {string | undefined} A string describing the differences if values differ, | ||
| * or `undefined` if they are identical. | ||
| */ | ||
| function compare(value1: string | object, value2: string | object): string | undefined { | ||
| try { | ||
| assert.deepStrictEqual(value1, value2); | ||
| } catch (e) { | ||
| return e.message; | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * Recursively checks whether all properties and values of the `expected` object | ||
| * are contained within the `actual` object. | ||
| * | ||
| * @param expected The reference value or object to compare against. | ||
| * @param actual The object or value being tested for containment. | ||
| * @param path (Internal) The current object path used for detailed mismatch reporting. | ||
| * @returns A descriptive error string indicating the first mismatch or missing key, | ||
| * or `undefined` if `actual` fully contains `expected`. | ||
| */ | ||
| function deepContains(expected: unknown, actual: unknown, path = ''): string | undefined { | ||
| // Handle primitive values (and null) | ||
| if (typeof expected !== 'object' || expected === null || typeof actual !== 'object' || actual === null) { | ||
| return Object.is(expected, actual) ? undefined : `Mismatch at ${path}: expected ${expected}, got ${actual}`; | ||
| } | ||
|
|
||
| // At this point both are non-null objects | ||
| const expectedObj = expected as Record<string, unknown>; | ||
| const actualObj = actual as Record<string, unknown>; | ||
|
|
||
| for (const key of Object.keys(expectedObj)) { | ||
| if (!(key in actualObj)) { | ||
| return `Missing key at ${path}.${key}`; | ||
| } | ||
|
|
||
| const result = deepContains(expectedObj[key], actualObj[key], `${path}.${key}`); | ||
| if (result) { | ||
| return result; | ||
| } | ||
| } | ||
|
|
||
| // Ignore extra keys in `actual` | ||
| return undefined; | ||
| } |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.