diff --git a/packages/adp-mock-server/.eslintignore b/packages/adp-mock-server/.eslintignore new file mode 100644 index 00000000000..1521c8b7652 --- /dev/null +++ b/packages/adp-mock-server/.eslintignore @@ -0,0 +1 @@ +dist diff --git a/packages/adp-mock-server/.eslintrc.js b/packages/adp-mock-server/.eslintrc.js new file mode 100644 index 00000000000..b717f83ae98 --- /dev/null +++ b/packages/adp-mock-server/.eslintrc.js @@ -0,0 +1,7 @@ +module.exports = { + extends: ['../../.eslintrc'], + parserOptions: { + project: './tsconfig.eslint.json', + tsconfigRootDir: __dirname + } +}; diff --git a/packages/adp-mock-server/.gitignore b/packages/adp-mock-server/.gitignore new file mode 100644 index 00000000000..68c8da22c2e --- /dev/null +++ b/packages/adp-mock-server/.gitignore @@ -0,0 +1,2 @@ +mock-data +CertificateAuthorityCertificate.pem \ No newline at end of file diff --git a/packages/adp-mock-server/README.md b/packages/adp-mock-server/README.md new file mode 100644 index 00000000000..e69de29bb2d diff --git a/packages/adp-mock-server/jest.config.js b/packages/adp-mock-server/jest.config.js new file mode 100644 index 00000000000..2ceece4bdda --- /dev/null +++ b/packages/adp-mock-server/jest.config.js @@ -0,0 +1,6 @@ +const config = require('../../jest.base'); +config.displayName = 'adp-mock-server'; +config.rootDir = './'; +config.testMatch = ['/test/unit/**/*.test.(ts|js)']; +config.collectCoverageFrom = ['/src/**/*.ts', '!/src/index.ts']; +module.exports = config; diff --git a/packages/adp-mock-server/package.json b/packages/adp-mock-server/package.json new file mode 100644 index 00000000000..bfa0fe009d6 --- /dev/null +++ b/packages/adp-mock-server/package.json @@ -0,0 +1,47 @@ +{ + "name": "@sap-ux/adp-mock-server", + "version": "0.0.1", + "description": "Adaptation project mock server", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "files": [ + "dist", + "README.md", + "!dist/*.map", + "!dist/**/*.map" + ], + "scripts": { + "build": "tsc --build", + "start": "ts-node ./src/index.ts --start", + "stop": "ts-node ./src/index.ts --stop", + "start:record": "ts-node ./src/index.ts --start --record", + "stop:record": "ts-node ./src/index.ts --stop --record", + "clean": "rimraf dist *.tsbuildinfo", + "test": "jest --ci --forceExit --detectOpenHandles --colors", + "watch": "tsc --build --watch" + }, + "repository": { + "type": "git", + "url": "https://github.com/SAP/open-ux-tools.git", + "directory": "packages/adp-mock-server" + }, + "license": "Apache-2.0", + "dependencies": { + "@sap-ux/logger": "workspace:*", + "adm-zip": "0.5.10", + "dotenv": "16.3.1", + "lodash": "4.17.21", + "mockserver-client": "5.15.0" + }, + "devDependencies": { + "@types/adm-zip": "0.5.5", + "@types/lodash": "4.14.202", + "mockserver-node": "5.15.0", + "rimraf": "5.0.5", + "ts-node": "10.9.2", + "typescript": "5.9.3" + }, + "engines": { + "node": ">=20.0" + } +} \ No newline at end of file diff --git a/packages/adp-mock-server/src/client/record-client.ts b/packages/adp-mock-server/src/client/record-client.ts new file mode 100644 index 00000000000..d3a4c90a562 --- /dev/null +++ b/packages/adp-mock-server/src/client/record-client.ts @@ -0,0 +1,52 @@ +import fs from 'fs/promises'; +import { omit, once } from 'lodash'; +import { mockServerClient } from 'mockserver-client'; +import type { MockServerClient } from 'mockserver-client/mockServerClient'; +import { MOCK_SERVER_PORT, RESPONSES_JSON_PATH } from '../server-constants'; +import { HttpRequestAndHttpResponse } from '../types'; +import { logger } from '../utils/logger'; +import { getSapSystemPort } from '../utils/sap-system-utils'; + +export const getRecordClient = once(getRecordClientInternal); + +export async function recordRequestsAndResponses(): Promise { + logger.info('Record responses.'); + const client = await getRecordClient(); + let requestAndResponseList = await retrieveRecordedRequestsAndResponses(client); + // TODO a.vasilev reponse.body = true does not work, mock server validation schema error is thrown when the server is started, + // need to be "true" what about false, maybe we can set the body to undefined for false values?! + requestAndResponseList = disableRequestsSecureFlag(requestAndResponseList); + await fs.writeFile(RESPONSES_JSON_PATH, JSON.stringify(requestAndResponseList, null, 2)); +} + +async function retrieveRecordedRequestsAndResponses(client: MockServerClient): Promise { + return client.retrieveRecordedRequestsAndResponses({}) as Promise; +} + +function disableRequestsSecureFlag(requestAndResponseList: HttpRequestAndHttpResponse[]): HttpRequestAndHttpResponse[] { + return requestAndResponseList.map(({ httpRequest, httpResponse }) => ({ + httpRequest: { + // TODO a.vasilev: Sometimes the settings request does not match + // the recordings due to a changed cookie. How is this possible, sb + // is changing the SAP_SESSIONID cookie in replay mode, how is that even + // possible??? + ...omit(httpRequest, ['headers', 'cookies']), + secure: false + }, + httpResponse + })); +} + +async function getRecordClientInternal(): Promise { + logger.info('Init mock server client.'); + const client = mockServerClient('localhost', MOCK_SERVER_PORT); + await client.mockAnyResponse({ + httpForward: { + // Forwards https requests to the actual sap system. + scheme: 'HTTPS', + host: process.env.SAP_SYSTEM_HOST, + port: getSapSystemPort() + } + }); + return client; +} diff --git a/packages/adp-mock-server/src/client/replay-client.ts b/packages/adp-mock-server/src/client/replay-client.ts new file mode 100644 index 00000000000..0d6b06115d9 --- /dev/null +++ b/packages/adp-mock-server/src/client/replay-client.ts @@ -0,0 +1,93 @@ +import { isEqual, once } from 'lodash'; +import { HttpRequest, HttpResponse } from 'mockserver-client'; +import { mockServerClient, MockServerClient } from 'mockserver-client/mockServerClient'; +import { MOCK_SERVER_PORT } from '../server-constants'; +import { Expectation } from '../types'; +import { normalizeZipFileContent } from '../utils/file-utils'; +import { HashMap, HashMapKeyComparator } from '../utils/hash-map'; +import { HashSet } from '../utils/hash-set'; +import { logger } from '../utils/logger'; +import { isZipBody } from '../utils/type-guards'; + +const NOT_FOUND_RESPONSE: HttpResponse = { + statusCode: 404, + reasonPhrase: '[ADP] Not found.' +}; + +type RequestMatcher = Pick; + +const zipRequestsComparator: HashMapKeyComparator = (requestA, requestB) => + requestA.path === requestB.path && + requestA.method === requestB.method && + isEqual(requestA.queryStringParameters, requestB.queryStringParameters) && + isZipBodyEqualWith(requestA.body, requestB.body); + +const isZipBodyEqualWith = (aBody: unknown, bBody: unknown) => { + if (!isZipBody(aBody) || !isZipBody(bBody)) { + return false; + } + const aBodyBuffer = Buffer.from(aBody.base64Bytes, 'base64'); + const bBodyBuffer = Buffer.from(bBody.base64Bytes, 'base64'); + return normalizeZipFileContent(aBodyBuffer) === normalizeZipFileContent(bBodyBuffer); +}; + +const requestMatcherComparator = (matcherA: RequestMatcher, matcherB: RequestMatcher) => + matcherA.path === matcherB.path && matcherA.method === matcherB.method; + +export const getReplayClient = once(getReplayClientInternal); + +async function getReplayClientInternal(): Promise { + logger.info('Init mock server client.'); + const client = mockServerClient('localhost', MOCK_SERVER_PORT); + await patchZipRequestsAndResponses(client); + return client; +} + +async function patchZipRequestsAndResponses(client: MockServerClient): Promise { + const expectationsList = await retrieveActiveExpectations(client); + const zipExpectationsList = expectationsList.filter(({ httpRequest }) => isZipBody(httpRequest?.body)); + const zipResponsesByRequestMap = new HashMap(zipRequestsComparator); + zipExpectationsList.forEach(({ httpRequest, httpResponse }) => { + if (!httpRequest || !httpResponse) { + return; + } + if (zipResponsesByRequestMap.has(httpRequest)) { + zipResponsesByRequestMap.get(httpRequest)?.push(httpResponse); + } else { + zipResponsesByRequestMap.set(httpRequest, [httpResponse]); + } + }); + const zipRequestMatcherSet = new HashSet(requestMatcherComparator); + const zipRequestMatcherList = Array.from(zipResponsesByRequestMap.keys()).map(({ path, method }) => ({ + path, + method + })); + zipRequestMatcherList.forEach((matcher) => { + zipRequestMatcherSet.add(matcher); + }); + const zipResponseIteratorsByRequestMap = zipResponsesByRequestMap.map( + ([httpRequest, httpResponses]) => [httpRequest, httpResponses.values()], + zipRequestsComparator + ); + + await Promise.all( + zipRequestMatcherSet.values().map((matcher) => + client.mockWithCallback( + matcher, + (httpRequest) => { + if (!zipResponseIteratorsByRequestMap.has(httpRequest)) { + return NOT_FOUND_RESPONSE; + } + const httpResponsesIterator = zipResponseIteratorsByRequestMap.get(httpRequest); + const nextResponse = httpResponsesIterator?.next().value; + return nextResponse ?? NOT_FOUND_RESPONSE; + }, + { unlimited: true } + ) + ) + ); +} + +async function retrieveActiveExpectations(client: MockServerClient): Promise { + return client.retrieveActiveExpectations({}) as Promise; +} diff --git a/packages/adp-mock-server/src/index.ts b/packages/adp-mock-server/src/index.ts new file mode 100644 index 00000000000..a49b931a211 --- /dev/null +++ b/packages/adp-mock-server/src/index.ts @@ -0,0 +1,82 @@ +import dotenv from 'dotenv'; +import { start_mockserver, stop_mockserver } from 'mockserver-node'; +import path from 'path'; +import { getRecordClient, recordRequestsAndResponses } from './client/record-client'; +import { getReplayClient } from './client/replay-client'; +import { EXPECTATIONS_JSON_PATH, MOCK_SERVER_PORT, RESPONSES_JSON_PATH } from './server-constants'; +import { getCliParamValueByName } from './utils/cli-utils'; +import { createMockDataFolderIfNeeded } from './utils/file-utils'; +import { logger } from './utils/logger'; +import { getSapSystemPort } from './utils/sap-system-utils'; + +const CLI_PARAM_START = 'start'; +const CLI_PARAM_STOP = 'stop'; +const CLI_PARAM_RECORD = 'record'; +const NOOP = new Promise(() => {}); + +dotenv.config({ path: path.join(__dirname, '../.env') }); + +async function start(isRecordOrReplayMode: boolean): Promise { + await createMockDataFolderIfNeeded(); + if (isRecordOrReplayMode) { + await startInRecordMode(); + } else { + await startInReplayMode(); + } +} + +async function startInRecordMode(): Promise { + await start_mockserver({ + serverPort: MOCK_SERVER_PORT, + // Does not support https forwarding without client https forwarding. + proxyRemoteHost: process.env.SAP_SYSTEM_HOST, + proxyRemotePort: getSapSystemPort(), + jvmOptions: [ + '-Dmockserver.watchInitializationJson=true', + `-Dmockserver.initializationJsonPath=${EXPECTATIONS_JSON_PATH}`, + '-Dmockserver.persistExpectations=true', + `-Dmockserver.persistedExpectationsPath=${EXPECTATIONS_JSON_PATH}` + ] + // verbose: true + }); + logger.info(`✅ Server running on port ${MOCK_SERVER_PORT} in record mode.`); + await getRecordClient(); +} + +async function startInReplayMode(): Promise { + await start_mockserver({ + serverPort: MOCK_SERVER_PORT, + jvmOptions: [ + '-Dmockserver.watchInitializationJson=true', + // We load all request/responses as expectations for the replay mode. + `-Dmockserver.initializationJsonPath=${RESPONSES_JSON_PATH}`, + '-Dmockserver.persistExpectations=false' + ] + // verbose: true + }); + logger.info(`✅ Server running on port ${MOCK_SERVER_PORT} in replay mode.`); + await getReplayClient(); +} + +async function stop(): Promise { + if (getCliParamValueByName(CLI_PARAM_RECORD)) { + await recordRequestsAndResponses(); + } + await stop_mockserver({ serverPort: MOCK_SERVER_PORT }); + logger.info('Stop mock server.'); +} + +async function main(): Promise { + if (getCliParamValueByName(CLI_PARAM_START)) { + await start(getCliParamValueByName(CLI_PARAM_RECORD)); + // Keep alive. + await NOOP; + } else if (getCliParamValueByName(CLI_PARAM_STOP)) { + await stop(); + } +} + +main().catch((error) => { + logger.error(`Unexpected error: ${error}.`); + process.exit(1); +}); diff --git a/packages/adp-mock-server/src/server-constants.ts b/packages/adp-mock-server/src/server-constants.ts new file mode 100644 index 00000000000..fd7dc8122ae --- /dev/null +++ b/packages/adp-mock-server/src/server-constants.ts @@ -0,0 +1,6 @@ +export const MOCK_SERVER_PORT = 1080; +export const DEFAULT_SAP_SYSTEM_PORT = 44355; + +export const MOCK_DATA_FOLDER_PATH = './mock-data'; +export const EXPECTATIONS_JSON_PATH = `${MOCK_DATA_FOLDER_PATH}/expectations.json`; +export const RESPONSES_JSON_PATH = `${MOCK_DATA_FOLDER_PATH}/responses.json`; diff --git a/packages/adp-mock-server/src/types.ts b/packages/adp-mock-server/src/types.ts new file mode 100644 index 00000000000..1c700a2043b --- /dev/null +++ b/packages/adp-mock-server/src/types.ts @@ -0,0 +1,31 @@ +import { HttpRequest, HttpResponse, Expectation as MockServerExpectation } from 'mockserver-client'; +import { HttpRequestAndHttpResponse as MockServerHttpRequestAndHttpResponse } from 'mockserver-client/mockServer'; + +export type HttpRequestAndHttpResponse = Omit & { + httpRequest?: HttpRequest; + httpResponse?: HttpResponse; +}; + +export type Expectation = Omit & { httpRequest?: HttpRequest }; + +export type BodyType = + | 'BINARY' + | 'JSON' + | 'JSON_SCHEMA' + | 'JSON_PATH' + | 'PARAMETERS' + | 'REGEX' + | 'STRING' + | 'XML' + | 'XML_SCHEMA' + | 'XPATH'; + +export interface Body { + type: BodyType; + not?: boolean; + contentType?: string; +} + +export interface BinaryBody extends Body { + base64Bytes: string; +} diff --git a/packages/adp-mock-server/src/utils/cli-utils.ts b/packages/adp-mock-server/src/utils/cli-utils.ts new file mode 100644 index 00000000000..6188af8a2dc --- /dev/null +++ b/packages/adp-mock-server/src/utils/cli-utils.ts @@ -0,0 +1,32 @@ +type CliParamValue = string | number | boolean | undefined; + +export function getCliParamValueByName(name: string): T { + const arg = process.argv.find((arg) => arg.startsWith(`--${name}`)); + + if (!arg) { + return undefined as T; + } + + const value = arg.split('=')[1]; + + if (!value) { + // If we have param without a value we return true, e.g. --record. + return true as T; + } + + const valueToLowerCase = value.toLowerCase(); + if (valueToLowerCase === 'true') { + return true as T; + } + + if (valueToLowerCase === 'false') { + return false as T; + } + + const numericValue = Number(value); + if (!isNaN(numericValue)) { + return numericValue as T; + } + + return value as T; +} diff --git a/packages/adp-mock-server/src/utils/file-utils.ts b/packages/adp-mock-server/src/utils/file-utils.ts new file mode 100644 index 00000000000..37c4e98a8d3 --- /dev/null +++ b/packages/adp-mock-server/src/utils/file-utils.ts @@ -0,0 +1,40 @@ +import AdmZip from 'adm-zip'; +import { createHash } from 'crypto'; +import fs from 'fs/promises'; +import { MOCK_DATA_FOLDER_PATH } from '../server-constants'; + +const SHA256_ALGORITHM = 'sha256'; + +interface FileInfo { + name: string; + hash: string; +} + +const alphabeticalOrder = (fileA: FileInfo, fileB: FileInfo) => fileA.name.localeCompare(fileB.name); + +export function createMockDataFolderIfNeeded(): Promise { + return fs.mkdir(MOCK_DATA_FOLDER_PATH, { recursive: true }); +} + +export function normalizeZipFileContent(buffer: Buffer): string { + const zip = new AdmZip(buffer); + const entries = zip.getEntries(); + + // Create a deterministic stable representation. + const fileInfos = entries.map((entry) => ({ + name: entry.entryName, + // Use a stable hash of file content. + hash: getFileContentStableHash(entry.getData()) + })); + + // Sort so order doesn’t matter. + fileInfos.sort(alphabeticalOrder); + + return JSON.stringify(fileInfos); +} + +function getFileContentStableHash(fileDataBuffer: Buffer): string { + // We use Uint8Array since Buffer inherits Uint8Array. + const fileDataBufferToArray = Uint8Array.from(fileDataBuffer); + return createHash(SHA256_ALGORITHM).update(fileDataBufferToArray).digest('hex'); +} diff --git a/packages/adp-mock-server/src/utils/hash-map.ts b/packages/adp-mock-server/src/utils/hash-map.ts new file mode 100644 index 00000000000..4a473dde544 --- /dev/null +++ b/packages/adp-mock-server/src/utils/hash-map.ts @@ -0,0 +1,66 @@ +export type HashMapKeyComparator = (keyA: K, keyB: K) => boolean; + +export class HashMap { + private readonly keyValueMap: Map; + + constructor(private readonly keyComparator: HashMapKeyComparator) { + this.keyValueMap = new Map(); + } + + set(key: Key, value: Value): this { + const existingKey = this.findKey(key); + if (existingKey) { + this.keyValueMap.set(existingKey, value); + } else { + this.keyValueMap.set(key, value); + } + return this; + } + + has(key: Key): boolean { + return !!this.findKey(key); + } + + get(key: Key): Value | undefined { + const existingKey = this.findKey(key); + if (existingKey) { + return this.keyValueMap.get(existingKey); + } + return undefined; + } + + entries(): MapIterator<[Key, Value]> { + return this.keyValueMap.entries(); + } + + keys(): MapIterator { + return this.keyValueMap.keys(); + } + + map( + entryFactory: (entry: [Key, Value]) => [OutKey, OutValue], + keyComparator: HashMapKeyComparator + ): HashMap { + return HashMap.fromEntries(Array.from(this.entries()).map(entryFactory), keyComparator); + } + + private findKey(targetKey: Key): Key | undefined { + for (let [key] of this.keyValueMap) { + if (this.keyComparator(key, targetKey)) { + return key; + } + } + return undefined; + } + + static fromEntries( + entries: [Key, Value][], + keyComparator: HashMapKeyComparator + ): HashMap { + const hashMap = new HashMap(keyComparator); + for (const [key, value] of entries) { + hashMap.set(key, value); + } + return hashMap; + } +} diff --git a/packages/adp-mock-server/src/utils/hash-set.ts b/packages/adp-mock-server/src/utils/hash-set.ts new file mode 100644 index 00000000000..6fc7e5ace6c --- /dev/null +++ b/packages/adp-mock-server/src/utils/hash-set.ts @@ -0,0 +1,22 @@ +export type HashSetComparator = (valueA: V, valueB: V) => boolean; + +export class HashSet { + private valuesList: Value[] = []; + + constructor(private readonly comparator: HashSetComparator) {} + + add(value: Value): this { + if (!this.has(value)) { + this.valuesList.push(value); + } + return this; + } + + has(value: Value): boolean { + return this.valuesList.some((existingValue) => this.comparator(value, existingValue)); + } + + values(): Value[] { + return this.valuesList.concat(); + } +} diff --git a/packages/adp-mock-server/src/utils/logger.ts b/packages/adp-mock-server/src/utils/logger.ts new file mode 100644 index 00000000000..690e194c944 --- /dev/null +++ b/packages/adp-mock-server/src/utils/logger.ts @@ -0,0 +1,5 @@ +import { ConsoleTransport, ToolsLogger } from '@sap-ux/logger'; + +const ADP_MOCK_SERVER_LOG_PREFIX = '[ADP][Mock server]'; + +export const logger = new ToolsLogger({ logPrefix: ADP_MOCK_SERVER_LOG_PREFIX, transports: [new ConsoleTransport()] }); diff --git a/packages/adp-mock-server/src/utils/sap-system-utils.ts b/packages/adp-mock-server/src/utils/sap-system-utils.ts new file mode 100644 index 00000000000..7da8a0fcc56 --- /dev/null +++ b/packages/adp-mock-server/src/utils/sap-system-utils.ts @@ -0,0 +1,5 @@ +import { DEFAULT_SAP_SYSTEM_PORT } from '../server-constants'; + +export function getSapSystemPort(): number { + return parseInt(process.env.SAP_SYSTEM_PORT ?? DEFAULT_SAP_SYSTEM_PORT.toString(), 10); +} diff --git a/packages/adp-mock-server/src/utils/type-guards.ts b/packages/adp-mock-server/src/utils/type-guards.ts new file mode 100644 index 00000000000..0f0a5d528a6 --- /dev/null +++ b/packages/adp-mock-server/src/utils/type-guards.ts @@ -0,0 +1,20 @@ +import { BinaryBody, Body } from '../types'; + +const ZIP_CONTENT_TYPE = 'application/zip'; + +export function isBodyType(body: unknown): body is Body { + return typeof body === 'object' && body !== null && 'type' in body && typeof body.type === 'string'; +} + +export function isBinaryBody(body: unknown): body is BinaryBody { + return isBodyType(body) && body.type === 'BINARY' && typeof (body as any).base64Bytes === 'string'; +} + +export function isZipBody(body: unknown): body is BinaryBody { + return ( + isBodyType(body) && + body.type === 'BINARY' && + typeof (body as any).base64Bytes === 'string' && + (body as any).contentType === ZIP_CONTENT_TYPE + ); +} diff --git a/packages/adp-mock-server/test/fixtures/zipBase64Bytes1.txt b/packages/adp-mock-server/test/fixtures/zipBase64Bytes1.txt new file mode 100644 index 00000000000..a0d1c5a9423 --- /dev/null +++ b/packages/adp-mock-server/test/fixtures/zipBase64Bytes1.txt @@ -0,0 +1 @@ +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 \ No newline at end of file diff --git a/packages/adp-mock-server/test/fixtures/zipBase64Bytes2.txt b/packages/adp-mock-server/test/fixtures/zipBase64Bytes2.txt new file mode 100644 index 00000000000..ce2061c3b44 --- /dev/null +++ b/packages/adp-mock-server/test/fixtures/zipBase64Bytes2.txt @@ -0,0 +1 @@ +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 \ No newline at end of file diff --git a/packages/adp-mock-server/test/unit/index.test.ts b/packages/adp-mock-server/test/unit/index.test.ts new file mode 100644 index 00000000000..5c9f9af2621 --- /dev/null +++ b/packages/adp-mock-server/test/unit/index.test.ts @@ -0,0 +1,16 @@ +import type { ToolsLogger } from '@sap-ux/logger'; + +describe('main', () => { + let loggerMock: jest.Mocked; + + beforeEach(() => { + loggerMock = { + info: jest.fn(), + error: jest.fn(), + debug: jest.fn(), + warn: jest.fn() + } as any; + }); + + test('should ', () => {}); +}); diff --git a/packages/adp-mock-server/test/unit/utils/file-utils.test.ts b/packages/adp-mock-server/test/unit/utils/file-utils.test.ts new file mode 100644 index 00000000000..08f60d9affa --- /dev/null +++ b/packages/adp-mock-server/test/unit/utils/file-utils.test.ts @@ -0,0 +1,197 @@ +import AdmZip from 'adm-zip'; +import { createHash } from 'crypto'; +import fs from 'fs/promises'; +import { MOCK_DATA_FOLDER_PATH } from '../../../src/server-constants'; +import { createMockDataFolderIfNeeded, normalizeZipFileContent } from '../../../src/utils/file-utils'; +import { readFixture } from '../../utils/fixture-utils'; + +jest.mock('fs/promises'); +const mockFs = fs as jest.Mocked; + +describe('file-utils', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('createMockDataFolderIfNeeded', () => { + test('should return the result from fs.mkdir', async () => { + const expectedResult = 'mock-result' as any; + mockFs.mkdir.mockResolvedValue(expectedResult); + + const result = await createMockDataFolderIfNeeded(); + + expect(result).toBe(expectedResult); + expect(mockFs.mkdir).toHaveBeenCalledWith(MOCK_DATA_FOLDER_PATH, { recursive: true }); + }); + }); + + describe('normalizeZipFileContent', () => { + test('should return deterministic output for same ZIP content', () => { + const files = [ + { name: 'file1.txt', content: 'content1' }, + { name: 'file2.txt', content: 'content2' } + ]; + const zipBuffer = createZipBuffer(files); + + const result1 = normalizeZipFileContent(zipBuffer); + const result2 = normalizeZipFileContent(zipBuffer); + + expect(result1).toBe(result2); + expect(zipBuffer); + }); + + test('should return deterministic output for same ZIP content when the zip buffer has different base64 byte representation (ABAP behaviour)', () => { + const zipBase64Bytes1 = readFixture('zipBase64Bytes1.txt'); + const zipBase64Bytes2 = readFixture('zipBase64Bytes2.txt'); + + const zipBuffer1 = Buffer.from(zipBase64Bytes1, 'base64'); + const zipBuffer2 = Buffer.from(zipBase64Bytes2, 'base64'); + + const result1 = normalizeZipFileContent(zipBuffer1); + const result2 = normalizeZipFileContent(zipBuffer2); + + expect(zipBase64Bytes1).not.toEqual(zipBase64Bytes2); + expect(result1).toEqual(result2); + }); + + test('should sort files alphabetically by name', () => { + const files = [ + { name: 'c-file.txt', content: 'content-c' }, + { name: 'a-file.txt', content: 'content-a' }, + { name: 'b-file.txt', content: 'content-b' } + ]; + const zipBuffer = createZipBuffer(files); + + const result = normalizeZipFileContent(zipBuffer); + const parsed = JSON.parse(result); + + expect(parsed).toHaveLength(3); + expect(parsed[0].name).toBe('a-file.txt'); + expect(parsed[1].name).toBe('b-file.txt'); + expect(parsed[2].name).toBe('c-file.txt'); + }); + + test('should generate correct file hashes', () => { + const files = [{ name: 'test.txt', content: 'test content' }]; + const zipBuffer = createZipBuffer(files); + + const result = normalizeZipFileContent(zipBuffer); + const parsed = JSON.parse(result); + + const expectedHash = getFileContentStableHash('test content'); + expect(parsed[0]).toEqual({ + name: 'test.txt', + hash: expectedHash + }); + expect(parsed).toHaveLength(1); + }); + + test('should handle empty ZIP files', () => { + const zip = new AdmZip(); + const emptyZipBuffer = zip.toBuffer(); + + const result = normalizeZipFileContent(emptyZipBuffer); + const parsed = JSON.parse(result); + + expect(parsed).toEqual([]); + }); + + test('should produce same result regardless of file addition order', () => { + // Create ZIP with files in different orders + const files1 = [ + { name: 'file1.txt', content: 'content1' }, + { name: 'file2.txt', content: 'content2' } + ]; + const files2 = [ + { name: 'file2.txt', content: 'content2' }, + { name: 'file1.txt', content: 'content1' } + ]; + + const zipBuffer1 = createZipBuffer(files1); + const zipBuffer2 = createZipBuffer(files2); + + const result1 = normalizeZipFileContent(zipBuffer1); + const result2 = normalizeZipFileContent(zipBuffer2); + + expect(result1).toBe(result2); + }); + + test('should handle files with same content but different names', () => { + const files = [ + { name: 'file1.txt', content: 'same content' }, + { name: 'file2.txt', content: 'same content' } + ]; + const zipBuffer = createZipBuffer(files); + + const result = normalizeZipFileContent(zipBuffer); + const parsed = JSON.parse(result); + + expect(parsed).toHaveLength(2); + expect(parsed[0].name).toBe('file1.txt'); + expect(parsed[1].name).toBe('file2.txt'); + // Both should have the same hash since content is identical + expect(parsed[0].hash).toBe(parsed[1].hash); + }); + + test('should handle files with different content', () => { + const files = [ + { name: 'file1.txt', content: 'content1' }, + { name: 'file2.txt', content: 'content2' } + ]; + const zipBuffer = createZipBuffer(files); + + const result = normalizeZipFileContent(zipBuffer); + const parsed = JSON.parse(result); + + expect(parsed).toHaveLength(2); + expect(parsed[0].hash).not.toBe(parsed[1].hash); + }); + + test('should handle binary content', () => { + const zip = new AdmZip(); + const binaryContent = Buffer.from([0x00, 0x01, 0x02, 0xff]); + zip.addFile('binary.bin', binaryContent); + const zipBuffer = zip.toBuffer(); + + const result = normalizeZipFileContent(zipBuffer); + const parsed = JSON.parse(result); + + expect(parsed).toHaveLength(1); + expect(parsed[0].name).toBe('binary.bin'); + expect(typeof parsed[0].hash).toBe('string'); + expect(parsed[0].hash).toHaveLength(64); // SHA256 hex length + }); + + test('should handle special characters in filenames', () => { + const files = [ + { name: 'файл.txt', content: 'unicode content' }, + { name: 'file with spaces.txt', content: 'space content' }, + { name: 'file-with-dashes.txt', content: 'dash content' } + ]; + const zipBuffer = createZipBuffer(files); + + const result = normalizeZipFileContent(zipBuffer); + const parsed = JSON.parse(result); + + expect(parsed).toHaveLength(3); + // Should be sorted alphabetically + const names = parsed.map((f: any) => f.name); + const sortedNames = [...names].sort(); + expect(names).toEqual(sortedNames); + }); + }); +}); + +function createZipBuffer(files: Array<{ name: string; content: string }>): Buffer { + const zip = new AdmZip(); + files.forEach(({ name, content }) => { + zip.addFile(name, Buffer.from(content, 'utf-8')); + }); + return zip.toBuffer(); +} + +function getFileContentStableHash(content: string): string { + const buffer = Buffer.from(content, 'utf-8'); + const uint8Array = Uint8Array.from(buffer); + return createHash('sha256').update(uint8Array).digest('hex'); +} diff --git a/packages/adp-mock-server/test/utils/fixture-utils.ts b/packages/adp-mock-server/test/utils/fixture-utils.ts new file mode 100644 index 00000000000..4b735338e1d --- /dev/null +++ b/packages/adp-mock-server/test/utils/fixture-utils.ts @@ -0,0 +1,6 @@ +import fs from 'fs'; +import path from 'path'; + +export function readFixture(name: string): string { + return fs.readFileSync(path.resolve(__dirname, '..', 'fixtures', name), 'utf8'); +} diff --git a/packages/adp-mock-server/tsconfig.eslint.json b/packages/adp-mock-server/tsconfig.eslint.json new file mode 100644 index 00000000000..d5f1aa34747 --- /dev/null +++ b/packages/adp-mock-server/tsconfig.eslint.json @@ -0,0 +1,4 @@ +{ + "extends": "./tsconfig.json", + "include": ["src", "test", ".eslintrc.js"] +} diff --git a/packages/adp-mock-server/tsconfig.json b/packages/adp-mock-server/tsconfig.json new file mode 100644 index 00000000000..bb98063b5ad --- /dev/null +++ b/packages/adp-mock-server/tsconfig.json @@ -0,0 +1,19 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src", + "composite": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "typeRoots": ["./node_modules/@types", "./types", "../../node_modules/@types"] + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist", "test"], + "references": [ + { + "path": "../logger" + } + ] +} diff --git a/packages/adp-mock-server/types/mockserver-node.d.ts b/packages/adp-mock-server/types/mockserver-node.d.ts new file mode 100644 index 00000000000..ec51795e79f --- /dev/null +++ b/packages/adp-mock-server/types/mockserver-node.d.ts @@ -0,0 +1,16 @@ +declare module 'mockserver-node' { + interface MockServerOptions { + serverPort?: number; + /** + * Does not support https forwarding without client https forwarding. + */ + proxyRemotePort?: number; + proxyRemoteHost?: string; + verbose?: boolean; + trace?: boolean; + jvmOptions?: string[]; + } + + export function start_mockserver(options?: MockServerOptions): Promise; + export function stop_mockserver(options?: MockServerOptions): Promise; +} \ No newline at end of file diff --git a/packages/adp-tooling/src/base/abap/manifest-service.ts b/packages/adp-tooling/src/base/abap/manifest-service.ts index 1cbe5d3d10d..d0635870a0e 100644 --- a/packages/adp-tooling/src/base/abap/manifest-service.ts +++ b/packages/adp-tooling/src/base/abap/manifest-service.ts @@ -148,6 +148,15 @@ export class ManifestService { const buffer = zip.toBuffer(); const lrep = this.provider.getLayeredRepository(); await lrep.getCsrfToken(); + const entries = zip.getEntries(); + // Create a deterministic stable representation. + const fileInfos = entries.map((entry) => ({ + name: entry.entryName, + // Use a stable hash of file content. + data: entry.getData().toString() + })); + this.logger.info('[avasilev][zip]' + JSON.stringify(fileInfos)); + this.logger.info('[buffer base64 string]' + buffer.toString('base64')); const response = await lrep.mergeAppDescriptorVariant(buffer); this.manifest = response[descriptorVariantId].manifest; } diff --git a/packages/adp-tooling/src/preview/adp-preview.ts b/packages/adp-tooling/src/preview/adp-preview.ts index 3aca81fc500..24846914c41 100644 --- a/packages/adp-tooling/src/preview/adp-preview.ts +++ b/packages/adp-tooling/src/preview/adp-preview.ts @@ -169,7 +169,15 @@ export class AdpPreview { zip.addFile(file.getPath().substring(1), await file.getBuffer()); } const buffer = zip.toBuffer(); - + const entries = zip.getEntries(); + // Create a deterministic stable representation. + const fileInfos = entries.map((entry) => ({ + name: entry.entryName, + // Use a stable hash of file content. + data: entry.getData().toString() + })); + this.logger.info('[avasilev][zip]' + JSON.stringify(fileInfos)); + this.logger.info('[buffer base64 string]' + buffer.toString('base64')); this.mergedDescriptor = (await this.lrep.mergeAppDescriptorVariant(buffer, '//'))[this.descriptorVariantId]; global.__SAP_UX_MANIFEST_SYNC_REQUIRED__ = false; } diff --git a/packages/axios-extension/src/index.ts b/packages/axios-extension/src/index.ts index 12db2e58565..64df546bcbc 100644 --- a/packages/axios-extension/src/index.ts +++ b/packages/axios-extension/src/index.ts @@ -7,5 +7,6 @@ export * from './abap'; export * from './factory'; export * from './auth'; export * from './abap/message'; +export * from './utils/axios-traffic'; export { ServiceType } from './abap/catalog/base'; export { AxiosError, AxiosRequestConfig, isAxiosError }; diff --git a/packages/axios-extension/src/utils/axios-traffic.ts b/packages/axios-extension/src/utils/axios-traffic.ts new file mode 100644 index 00000000000..59174b88811 --- /dev/null +++ b/packages/axios-extension/src/utils/axios-traffic.ts @@ -0,0 +1,192 @@ +import type { ToolsLogger } from '@sap-ux/logger'; +import type { AxiosRequestConfig, AxiosResponse } from 'axios'; +import { Axios } from 'axios'; +import type { WriteStream } from 'fs'; +import fs from 'fs'; +import { once } from 'lodash'; +import { rename, access, constants } from 'fs/promises'; +import { getResponseMapFromHar } from './export-from-har'; +import { once as Once } from 'events'; + +// We call the actual function with once because we want sort of idempotency. +// For example when called in yo generator, the generator could be started multiple times +// by the Application Wizard while changing pages, so we want to patch the Axios.request() +// method only once not for each page visit. +export const logAxiosTraffic = once(logAxiosTrafficInternal); + +const QUERY_PARAMS_PREFIX = '?'; +const QUERY_PARAMS_SEPARATOR = '&'; +const GET_REQUEST_METHOD = 'get'; +const SAP_CLIENT_QUERY_PARAM_NAME = 'sap-client'; +const TEMP_MUAB_CONFIG_PATH = '/Users/I762682/projects/temp-muab-config.txt'; +//path.join(os.tmpdir(), 'temp-muab-config.txt'); + +type URLQueryParams = string | URLSearchParams | Record | string[][]; + +interface MuabResponse { + relativeUrl: string; + method: string; + statusCode: number; + body: any; +} + +function logAxiosTrafficInternal(logger: ToolsLogger, isGeneratorWorkflow: boolean = true) { + const prototype = Axios.prototype; + const originalRequest = prototype.request; + const muabConfigStream = fs.createWriteStream(TEMP_MUAB_CONFIG_PATH, { flags: 'w' }); + logger.info('[axios] Start dump'); + + appendNewLine(muabConfigStream, `## Automated muab config file.`); + + prototype.request = async function patchRequest, D = any>( + this: Axios, + config: AxiosRequestConfig + ): Promise { + // This config does not contain headers or query params added from interceptors. + const mergedConfig = { + ...this.defaults, + ...config, + headers: { + ...(this.defaults?.headers || {}), + ...(config.headers || {}) + } + }; + const requestUrl = getFullUrlString(mergedConfig.baseURL ?? '', mergedConfig.url ?? '', mergedConfig.params); + // If the developer omits the request method when dooes a call to the .request() method + // an internal axios interceptor sets the default method to a GET method, + // so wee need to do the same here. + const method = (mergedConfig.method ?? GET_REQUEST_METHOD).toUpperCase(); + + // logger.info(`[axios][=>][${method}] ${requestUrl}`); + // if (mergedConfig.headers) { + // logger.info(`[axios] headers: ${mergedConfig.headers}`); + // } + // if (mergedConfig.data) { + // logger.info(`[axios] body: ${mergedConfig.data}`); + // } + + try { + const response = await originalRequest.call(this, config); + // This config contains all data added from interceptors. + const responseConfig = response.config ?? {}; + const responseUrl = getFullUrlString( + responseConfig.baseURL ?? '', + responseConfig.url ?? '', + responseConfig.params + ); + + // logger.info(`[axios][<=][${response.status}] ${responseUrl}`); + // if (response.headers) { + // logger.info(`[axios] headers: ${response.headers}`); + // } + // if (response.data) { + // logger.info(`[axios] body: ${response.data}`); + // } + + appendMuabResponse( + muabConfigStream, + { + relativeUrl: response.request.path, + method, + statusCode: parseInt(response.status, 10), + body: response.data + }, + isGeneratorWorkflow + ); + + return response; + } catch (error) { + logger.error(`[axios][error] ${requestUrl} ${error.message}`); + if (error.response) { + logger.error(`[axios] status: ${error.response.status}`); + logger.error(`[axios] headers: ${error.response.headers}`); + logger.error(`[axios] body: ${error.response.data}`); + } + throw error; + } + }; + + return { + saveMuabConfig: (muabConfigPath: string) => saveMuabConfig(muabConfigStream, muabConfigPath) + }; +} + +function getFullUrlString(baseURL: string, relativeUrl: string, queryParams?: URLQueryParams): string { + try { + let fullUrl = new URL(relativeUrl, baseURL).toString(); + const paramsToString = queryParams ? new URLSearchParams(queryParams).toString() : ''; + if (paramsToString) { + const paramsPrefix = fullUrl.includes(QUERY_PARAMS_PREFIX) ? QUERY_PARAMS_SEPARATOR : QUERY_PARAMS_PREFIX; + const decodedParams = decodeURIComponent(paramsToString); + fullUrl += `${paramsPrefix}${decodedParams}`; + } + + return fullUrl; + } catch { + return `${baseURL}${relativeUrl}`; + } +} + +function appendMuabResponse(stream: WriteStream, response: MuabResponse, isGeneratorWorkflow: boolean): void { + appendNewLine(stream, `# [${isGeneratorWorkflow ? 'generator' : 'editor'}][axios] response`); + appendNewLine( + stream, + `${removeQueryParamFromPath(response.relativeUrl, SAP_CLIENT_QUERY_PARAM_NAME)};${response.method}|${ + response.statusCode + };body=${response.body}` + ); +} + +function appendNewLine(stream: WriteStream, message: string): void { + stream.write(`${message}\n\n`); +} + +async function saveMuabConfig(stream: WriteStream, muabConfigPath: string): Promise { + await mergeWithHarFile(stream, muabConfigPath); + await new Promise((resolve) => { + stream.end(() => resolve()); + }); + + await access(TEMP_MUAB_CONFIG_PATH, constants.F_OK); + await rename(TEMP_MUAB_CONFIG_PATH, muabConfigPath + '/muab-config.txt'); +} + +async function mergeWithHarFile(stream: WriteStream, muabConfigPath: string): Promise { + const harMap = getResponseMapFromHar(`${muabConfigPath}/localhost.har`); + + for (const [url, response] of Object.entries(harMap)) { + appendMuabResponse( + stream, + { + relativeUrl: url.replace('http://localhost:8080', ''), + method: response.method, + statusCode: 200, + body: response.body + }, + false + ); + + // ✅ Wait for the stream buffer to drain if full + if (!stream.writableNeedDrain) continue; + await Once(stream, 'drain'); + } +} + +/** + * Removes a query parameter from a URL path string. + * + * @param path - The URL path with optional query string + * @param paramName - The query parameter to remove + * @returns The path without the specified query parameter + */ +export function removeQueryParamFromPath(path: string, paramName: string): string { + // Use a dummy base so URL can parse relative paths + const url = new URL(path, 'http://dummy'); + + // Remove the parameter + url.searchParams.delete(paramName); + + // Return path + remaining query string + const newPath = url.pathname + decodeURIComponent(url.search); // exclude host and protocol + return newPath; +} diff --git a/packages/axios-extension/src/utils/export-from-har.ts b/packages/axios-extension/src/utils/export-from-har.ts new file mode 100644 index 00000000000..9605494c933 --- /dev/null +++ b/packages/axios-extension/src/utils/export-from-har.ts @@ -0,0 +1,121 @@ +/** + * har-to-map.ts + * + * Usage: + * npx ts-node har-to-map.ts [path/to/file.har] + * + * Produces: har-map.json in the current folder. + * + * Map format: + * { + * "http://example.com/path?query=1": { + * "method": "GET", + * "body": {...} // parsed JSON if JSON, otherwise string + * }, + * ... + * } + */ + +import * as fs from 'fs'; +import type { WriteStream } from 'fs'; + +interface HarEntry { + request: { + method: string; + url: string; + }; + response: { + content?: { + mimeType?: string; + text?: string; + encoding?: string; + }; + }; +} + +interface HarFile { + log?: { + entries?: HarEntry[]; + }; +} + +interface ResponseMapValue { + method: string; + body: unknown; +} + +type ResponseMap = Record; + +function safeParseJson(text: string): any { + try { + return JSON.parse(text); + } catch { + return text; + } +} + +function decodeIfBase64(text: string, encoding?: string): string { + if (!text) { + return ''; + } + if (encoding && encoding.toLowerCase() === 'base64') { + return Buffer.from(text, 'base64').toString('utf8'); + } + return text; +} + +function getResponseBody(entry: HarEntry): any { + const content = entry.response?.content; + if (!content?.text) { + return ''; + } + const decoded = decodeIfBase64(content.text, content.encoding); + return safeParseJson(decoded); +} + +export function getResponseMapFromHar(harPath: string): ResponseMap { + if (!fs.existsSync(harPath)) { + throw new Error(`HAR file not found: ${harPath}`); + } + + const raw = fs.readFileSync(harPath, 'utf8'); + let har: HarFile; + + let entries; + try { + har = JSON.parse(raw); + } catch (err) { + const entryRegex = /{"startedDateTime":.*?"response":\{.*?\}\s*}/gs; + const matches = raw.match(entryRegex); + entries = []; + if (matches) { + for (const match of matches) { + try { + const entry = JSON.parse(match); + entries.push(entry); + } catch { + // skip broken entries + } + } + } + } + + if (!entries) { + entries = har.log?.entries ?? []; + } + const map: ResponseMap = {}; + let skipped = 0; + + for (const entry of entries) { + const url = entry.request?.url; + if (!url) { + skipped++; + continue; + } + const method = entry.request?.method || 'GET'; + const body = getResponseBody(entry); + map[url] = { method, body }; + } + + return map; +} diff --git a/packages/axios-extension/src/utils/readme.md b/packages/axios-extension/src/utils/readme.md new file mode 100644 index 00000000000..de248b49fd5 --- /dev/null +++ b/packages/axios-extension/src/utils/readme.md @@ -0,0 +1,9 @@ +TODO +* This need to be put in a separate package, e.g. mock-server or muab ... +* Code quality needs improvement this `muab` branch is only for the demo. +* Check if .css or any static resources go through the muab if so how can we filter them we do nto want +to clutter the muab.config. + +* When we export .har, preserve logs in the checkbox need to be marked otherwise +the .har will contain ...show more for large response bodies being truncated and the .har json will be invalid. +This need to be verified. \ No newline at end of file diff --git a/packages/generator-adp/src/app/index.ts b/packages/generator-adp/src/app/index.ts index 32d2ee48802..e540dc21b61 100644 --- a/packages/generator-adp/src/app/index.ts +++ b/packages/generator-adp/src/app/index.ts @@ -16,7 +16,7 @@ import { type ConfigAnswers, type UI5Version } from '@sap-ux/adp-tooling'; -import type { AbapServiceProvider } from '@sap-ux/axios-extension'; +import { logAxiosTraffic, type AbapServiceProvider } from '@sap-ux/axios-extension'; import { isInternalFeaturesSettingEnabled } from '@sap-ux/feature-toggle'; import { TelemetryHelper, @@ -118,6 +118,8 @@ export default class extends Generator { */ private isCustomerBase: boolean; + private saveMuabConfig: (muabConfigPath: string) => void; + /** * Creates an instance of the generator. * @@ -311,6 +313,7 @@ export default class extends Generator { } async end(): Promise { + this.saveMuabConfig(`${this._getProjectPath()}/muab-config.txt`); if (this.shouldCreateExtProject) { return; } @@ -384,6 +387,8 @@ export default class extends Generator { this.options.logWrapper ); this.logger = AdpGeneratorLogger.logger as unknown as ToolsLogger; + const { saveMuabConfig } = logAxiosTraffic(this.logger); + this.saveMuabConfig = saveMuabConfig; } /** diff --git a/packages/preview-middleware/src/ui5/middleware.ts b/packages/preview-middleware/src/ui5/middleware.ts index de2cb42f366..c220ae93927 100644 --- a/packages/preview-middleware/src/ui5/middleware.ts +++ b/packages/preview-middleware/src/ui5/middleware.ts @@ -5,6 +5,7 @@ import { type EnhancedRouter, FlpSandbox, initAdp } from '../base/flp'; import type { MiddlewareConfig } from '../types'; import { getPreviewPaths, sanitizeConfig } from '../base/config'; import { logRemoteUrl, isRemoteConnectionsEnabled } from '../base/remote-url'; +import { logAxiosTraffic } from '@sap-ux/axios-extension'; /** * Create the router that is to be exposed as UI5 middleware. @@ -60,6 +61,13 @@ module.exports = async (params: MiddlewareParameters): Promise transports: [new UI5ToolingTransport({ moduleName: 'preview-middleware' })], logLevel: params.options.configuration?.debug ? LogLevel.Debug : LogLevel.Info }); + + const { saveMuabConfig } = logAxiosTraffic(logger, false); + process.on('SIGINT', async () => { + await saveMuabConfig(process.cwd()); + process.exit(); + }); + try { return await createRouter(params, logger); } catch (error) { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 29602276fb1..d362156f3be 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -584,6 +584,22 @@ importers: specifier: 6.3.0 version: 6.3.0(mem-fs@2.1.0)(yeoman-environment@3.8.0)(yeoman-generator@5.10.0) + packages/adp-mock-server: + dependencies: + '@sap-ux/logger': + specifier: workspace:* + version: link:../logger + devDependencies: + '@jest/types': + specifier: 30.0.1 + version: 30.0.1 + mockserver-node: + specifier: 5.15.0 + version: 5.15.0 + rimraf: + specifier: 5.0.5 + version: 5.0.5 + packages/adp-tooling: dependencies: '@sap-devx/yeoman-ui-types': @@ -19888,6 +19904,17 @@ packages: through: 2.3.8 dev: true + /mockserver-node@5.15.0: + resolution: {integrity: sha512-Po442dwW5GpTSgP2T2FbGFv8vp07llfTVJSZBZQZ2VoTFvkhRgCN1D1FqRFhTFZ/42aCTYsWxQnjp1Tt1nISxA==} + engines: {node: '>= 0.8.0'} + dependencies: + follow-redirects: 1.15.6(debug@4.4.1) + glob: 8.0.3 + q: 2.0.3 + transitivePeerDependencies: + - debug + dev: true + /module-details-from-path@1.0.3: resolution: {integrity: sha512-ySViT69/76t8VhE1xXHK6Ch4NcDd26gx0MzKXLO+F7NOtnqH68d9zF94nT8ZWSxXh8ELOERsnJO/sWt1xZYw5A==} dev: false diff --git a/tsconfig.json b/tsconfig.json index 6b0579db022..dc74cda0736 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -36,6 +36,9 @@ { "path": "packages/adp-flp-config-sub-generator" }, + { + "path": "packages/adp-mock-server" + }, { "path": "packages/adp-tooling" },