UNPKG

@openui5/sap.ui.core

Version:

OpenUI5 Core Library sap.ui.core

1,243 lines (1,145 loc) 43.3 kB
/*! * OpenUI5 * (c) Copyright 2026 SAP SE or an SAP affiliate company. * Licensed under the Apache License, Version 2.0 - see LICENSE.txt. */ sap.ui.define([ "sap/base/Log", "sap/base/util/merge", "sap/ui/base/SyncPromise", "sap/ui/core/Lib", "sap/ui/core/Rendering", "sap/ui/thirdparty/jquery" ], function (Log, merge, SyncPromise, Library, Rendering, jQuery) { "use strict"; /*global QUnit, sinon */ // Note: The dependency to Sinon.JS has been omitted deliberately. Most test files load it via // <script> anyway and declaring the dependency would cause it to be loaded twice. var rBatch = /\/\$batch($|\?)/, rContentId = /(?:^|\r\n)Content-Id\s*:\s*(\S+)/i, rContentIdReference = / \$([^ ?\/]+)/, rEndsWithJSON = /\.json$/i, rEndsWithXML = /\.xml$/i, rHeaderLine = /^(.*)?:\s*(.*)$/, sJson = "application/json;charset=UTF-8;IEEE754Compatible=true", mMessageForPath = {}, // a cache for files, see useFakeServer sMimeHeaders = "\r\nContent-Type: application/http\r\n" + "Content-Transfer-Encoding: binary\r\n", rMultipartHeader = /^Content-Type:\s*multipart\/mixed;\s*boundary=/i, oURLSearchParams = new URLSearchParams(window.location.search), sAutoRespondAfter = oURLSearchParams.get("autoRespondAfter"), sRealOData = oURLSearchParams.get("realOData"), rRequestKey = /^(\S+) (\S+)$/, rRequestLine = /^(GET|DELETE|MERGE|PATCH|POST) (\S+) HTTP\/1\.1$/, mData = {}, rODataHeaders = /^(OData-Version|DataServiceVersion)$/, bRealOData = sRealOData === "true" || sRealOData === "direct", fnOnRequest = null, rNonReadableChars = /[ "[\]{}]/g, rNonReadableEscaped = /%(20|22|5B|5D|7B|7D)/gi, rMethod = /^\w+ /, rResourcesAndOptionalCacheBusterToken = /(^|\/)resources\/(~[-a-zA-Z0-9_.]*~\/)?/, TestUtils; if (bRealOData) { document.title += " (real OData)"; } /** * Builds a server-absolute path from a path relative to test-resources and strips off the cache * buster token. * * @param {string} sPath - The relative path * @returns {string} - The resulting server-absolute path */ function absolutePath(sPath) { return sap.ui.require.toUrl(sPath) .replace(rResourcesAndOptionalCacheBusterToken, "$1test-resources/") + "/"; } /** * Gets the content type from the given resource name. * @param {string} sName The resource name * @returns {string} The content type */ function contentType(sName) { if (rEndsWithXML.test(sName)) { return "application/xml"; } if (rEndsWithJSON.test(sName)) { return sJson; } return "application/x-octet-stream"; } /** * Checks that the actual value deeply contains the expected value, ignoring additional * properties. * * @param {object} oActual * the actual value to be tested * @param {object|RegExp} oExpected * the expected value which needs to be contained structurally (as a subset) within the * actual value, or a regular expression which must match the actual string(!) value * @param {string} sPath * path to the values under investigation * @throws {Error} * in case the actual value does not deeply contain the expected value; the error message * provides a proof of this */ function deeplyContains(oActual, oExpected, sPath) { var sActualType = QUnit.objectType(oActual), sExpectedType = QUnit.objectType(oExpected), sName; if (sActualType === "string" && sExpectedType === "regexp") { if (!oExpected.test(oActual)) { throw new Error(sPath + ": actual value " + oActual + " does not match expected regular expression " + oExpected); } return; } if (sActualType !== sExpectedType) { throw new Error(sPath + ": actual type " + sActualType + " does not match expected type " + sExpectedType); } if (sActualType === "array") { if (oActual.length < oExpected.length) { throw new Error(sPath + ": array length: " + oActual.length + " < " + oExpected.length); } } if (sActualType === "array" || sActualType === "object") { for (sName in oExpected) { deeplyContains(oActual[sName], oExpected[sName], sPath === "/" ? sPath + sName : sPath + "/" + sName); } } else if (oActual !== oExpected) { throw new Error(sPath + ": actual value " + oActual + " does not match expected value " + oExpected); } } /** * Pushes a QUnit test which succeeds if and only if a call to {@link deeplyContains} succeeds * as indicated via <code>bExpectSuccess</code>. * * @param {object} oActual * the actual value to be tested * @param {object} oExpected * the expected value which needs to be contained structurally (as a subset) within the * actual value * @param {string} sMessage * message text * @param {boolean} bExpectSuccess * whether {@link deeplyContains} is expected to succeed */ function pushDeeplyContains(oActual, oExpected, sMessage, bExpectSuccess) { try { deeplyContains(oActual, oExpected, "/"); QUnit.assert.pushResult({ result : bExpectSuccess, actual : oActual, expected : oExpected, message : sMessage }); } catch (ex) { QUnit.assert.pushResult({ result : !bExpectSuccess, actual : oActual, expected : oExpected, message : (sMessage || "") + " failed because of " + ex.message }); } } /** * @classdesc * A collection of functions that support QUnit testing. * * @namespace sap.ui.test.TestUtils * @since 1.27.1 */ TestUtils = /** @lends sap.ui.test.TestUtils */ { /** * If the UI5 core is dirty, the function returns a promise that waits until the rendering * is finished. * * @returns {Promise} * A promise that is resolved when the UI5 core is no longer dirty * * @public */ awaitRendering : function () { return new Promise(function (resolve) { function check() { if (Rendering.isPending()) { setTimeout(check, 1); } else { resolve(); } } check(); }); }, /** * Checks for a given object its "get*" and "request*" methods, corresponding to the named * "fetch*" method, using the given arguments. * * @param {object} oTestContext * The QUnit "test" object * @param {object} oTestee * The test candidate having the get<sMethodName> created via * sap.ui.model.odata.v4.lib._Helper.createGetMethod and request<sMethodName> created via * sap.ui.model.odata.v4.lib._Helper.createRequestMethod for the corresponding * fetch<sMethodName> * @param {object} assert * The QUnit "assert" object * @param {string} sMethodName * Method name "fetch*" * @param {object[]} aArguments * Method arguments * @param {boolean} [bThrow] * Whether the "get*" method throws if the promise is not fulfilled * @returns {Promise} * The "request*" method's promise * * @see sap.ui.model.odata.v4.lib._Helper.createGetMethod * @see sap.ui.model.odata.v4.lib._Helper.createRequestMethod * @ui5-restricted sap.ui.model.odata.v4 */ checkGetAndRequest : function (oTestContext, oTestee, assert, sMethodName, aArguments, bThrow) { var oExpectation, sGetMethodName = sMethodName.replace("fetch", "get"), oPromiseMock = oTestContext.mock(Promise), oReason = new Error("rejected"), oRejectedPromise = Promise.reject(oReason), sRequestMethodName = sMethodName.replace("fetch", "request"), oResult = {}, oSyncPromise = SyncPromise.resolve(oRejectedPromise); // resolve... oExpectation = oTestContext.mock(oTestee).expects(sMethodName).exactly(4); oExpectation = oExpectation.withExactArgs.apply(oExpectation, aArguments); oExpectation.returns(SyncPromise.resolve(oResult)); // get: fulfilled assert.strictEqual(oTestee[sGetMethodName].apply(oTestee, aArguments), oResult); // reject... oExpectation.returns(oSyncPromise); oPromiseMock.expects("resolve") .withExactArgs(sinon.match.same(oSyncPromise)) .returns(oRejectedPromise); // return any promise (this is not unwrapping!) // request (promise still pending!) assert.strictEqual(oTestee[sRequestMethodName].apply(oTestee, aArguments), oRejectedPromise); // restore early so that JS coding executed from Selenium Webdriver does not cause // unexpected calls on the mock when it uses Promise.resolve and runs before automatic // mock reset in afterEach oPromiseMock.restore(); // get: pending if (bThrow) { assert.throws(function () { oTestee[sGetMethodName].apply(oTestee, aArguments); }, new Error("Result pending")); } else { assert.strictEqual(oTestee[sGetMethodName].apply(oTestee, aArguments), undefined, "pending"); } return oSyncPromise.catch(function () { // get: rejected if (bThrow) { assert.throws(function () { oTestee[sGetMethodName].apply(oTestee, aArguments); }, oReason); } else { assert.strictEqual(oTestee[sGetMethodName].apply(oTestee, aArguments), undefined, "rejected"); } }); }, /** * Companion to <code>QUnit.deepEqual</code> which only tests for the existence of expected * properties, not the absence of others. * * <b>BEWARE:</b> We assume both values to be JS object literals, basically! * * @param {object} oActual * the actual value to be tested * @param {object} oExpected * the expected value which needs to be contained structurally (as a subset) within the * actual value * @param {string} [sMessage] * message text * * @public */ deepContains : function (oActual, oExpected, sMessage) { pushDeeplyContains(oActual, oExpected, sMessage, true); }, /** * Fixes a human readable URL by percent-encoding space, double quotes, square brackets, and * curly brackets. * * @param {string} sUrl - The human readable URL * @return {string} The fixed URL * * @see #.makeUrlReadable */ encodeReadableUrl : function (sUrl) { return sUrl.replaceAll(rNonReadableChars, (s) => `%${s.charCodeAt(0).toString(16).padStart(2, "0").toUpperCase()}`); }, /** * Makes a URL better readable for humans by replacing the percent-encoding for space, * double quotes, square brackets and curly brackets. * * @param {string} sUrl - The URL * @return {string} The human readable URL * * @see #.encodeReadableUrl */ makeUrlReadable : function (sUrl) { return sUrl.replaceAll(rNonReadableEscaped, (_s, n) => String.fromCharCode(Number.parseInt(n, 16))); }, /** * Companion to <code>QUnit.notDeepEqual</code> and {@link #deepContains}. * * @param {object} oActual * the actual value to be tested * @param {object} oExpected * the expected value which needs to be NOT contained structurally (as a subset) within * the actual value * @param {string} [sMessage] * message text * * @public */ notDeepContains : function (oActual, oExpected, sMessage) { pushDeeplyContains(oActual, oExpected, sMessage, false); }, /** * Gets a Promise that resolves if all sources have been loaded asynchronously. * The <code>message</code> property of the response object is set with the source's content * and the <code>source</code> property is deleted from the response object. * * @param {map} mFixture * The fixture for {@link sap.ui.test.TestUtils.useFakeServer}; it is modified during the * call * @param {object[]} [aRegExps] * The regular expression array for {@link sap.ui.test.TestUtils.useFakeServer}; it is * modified during the call * @param {string} [sBase="sap/ui/core/qunit/odata/v4/data"] * The base path for {@link sap.ui.test.TestUtils.useFakeServer} * @returns {Promise} * A Promise that resolves with <code>undefined</code> when all sources are loaded; * rejects with an error if at least one source cannot be loaded * * @public */ requestAllSources : function (mFixture, aRegExps, sBase) { sBase = absolutePath(sBase || "sap/ui/core/qunit/odata/v4/data"); const mSource2Promise = new Map(); function addResponse(vResponse) { if (Array.isArray(vResponse)) { vResponse.forEach(addResponse); } else if (vResponse.source) { let oPromise = mSource2Promise.get(vResponse.source); if (!oPromise) { oPromise = fetch(sBase + vResponse.source) .then((oResponse) => oResponse.text()); mSource2Promise.set(vResponse.source, oPromise); } oPromise.then((sMessage) => { vResponse.message = sMessage; vResponse.headers ??= {}; vResponse.headers["Content-Type"] ||= contentType(vResponse.source); delete vResponse.source; }, () => { /* Caller is responsible for error handling, see Promise.all */ }); } } Object.values(mFixture).forEach(addResponse); aRegExps?.forEach((oRegExpFixture) => { addResponse(oRegExpFixture.response); }); return Promise.all(Array.from(mSource2Promise.values())).then(() => {}); }, /** * Activates a sinon fake server in the given sandbox. The fake server responds to those * requests given in the fixture, and to all DELETE, MERGE, PATCH, and POST requests * regardless of the path. It is automatically restored when the sandbox is restored. * * The function uses <a href="http://sinonjs.org/docs/">Sinon.js</a> and expects that it * has been loaded. * * POST requests ending on "/$batch" are handled automatically, unless a matching fixture is * given. They are expected to be multipart-mime requests where each part is a DELETE, GET, * PATCH, MERGE, or POST request. The response has a multipart-mime message containing * responses to these inner requests. If an inner request is not a DELETE, a MERGE, a PATCH, * or a POST, and it is not found in the fixture, or its message is not JSON, it is * responded with an error code. The batch itself is always responded with code 200. * * "$batch" requests with an OData change set are supported, too. For each request in the * change set a response is searched in the fixture. As long as all responses are success * responses (code less than 400) a change set response is returned. Otherwise, the first * error message is the response for the whole change set. * * All other POST requests with no matching response in the fixture are responded with code * 200, the body is simply echoed. * * DELETE, MERGE, and PATCH requests with no matching response in the fixture are responded * with code 204 ("No Content"). * * Direct HEAD requests with no matching response in the fixture are responded with code 200 * and no content. * * The headers "OData-Version" and "DataServiceVersion" are copied from the request to the * response unless specified in the fixture. * * @param {object} oSandbox * A Sinon sandbox as created using <code>sinon.sandbox.create()</code> * @param {string} sBase * The base path for <code>source</code> values in the fixture. The path must be in the * project's test folder, typically it should start with "sap". * Example: <code>"sap/ui/core/qunit/model"</code> * @param {map} mFixture * The fixture. Each key represents a method and a URL to respond to, in the form * "METHOD URL". The method "GET" may be omitted. Spaces, double quotes, square brackets, * and curly brackets inside the URL are percent-encoded automatically. The value is an * array or single response object that may have the following properties: * <ul> * <li> {number} <code>code</code>: The response code (<code>200</code> if not given) * <li> {map} <code>headers</code>: A map of headers to set in the response * <li> {RegExp|function} <code>ifMatch</code>: A filter to select the response. If not * given, all requests match. The first match in the list wins. A regular expression * is matched against the request body. A function is called with a request object * having properties method, url, requestHeaders and requestBody; it must return * truthy to indicate a match. * <li> {object|string} <code>message</code>: The response message, either as a string * or as an object which is serialized via <code>JSON.stringify</code> (the header * <code>Content-Type</code> will be set appropriately in this case) * <li> {string} <code>source</code>: The path of a file relative to <code>sBase</code> * to be used for the response message. It will be read synchronously in advance. In * this case the header <code>Content-Type</code> is determined from the source name's * extension unless specified. This has precedence over <code>message</code>. * </ul> * @param {object[]} [aRegExps] * An array containing regular expressions in the regExp property and the corresponding * response(s) objects in the response property. If no match for a request was found in * the normal fixture, the regular expressions are checked. The response object looks * exactly the same as in the fixture and may additionally contain a method * <code>buildResponse(aMatch, oResponse, oRequest, sReferencedMessage)</code> which gets * passed the match object, the response, the request, and optionally the referenced * response message in case of Content-ID referencing in order to allow modification of * the response before sending. If there are two matching regular expressions for a * $metadata requests, the first one is used. * @param {string} [sServiceUrl] * The service URL which determines a prefix for all requests the fake server responds to; * it responds with an error for requests not given in the fixture, except DELETE, MERGE, * PATCH, or POST. A missing URL is ignored. * @param {boolean} [bStrict] * Whether responses are created from the given fixture only, without defaults per method. * It does not prevent the automatic handling of <code>$batch</code>. * @returns {object} * The SinonJS fake server instance * * @public */ useFakeServer : function (oSandbox, sBase, mFixture, aRegExps, sServiceUrl, bStrict) { // a map from "method path" incl. service URL to a list of response objects with // properties code, headers, ifMatch and message var aRegexpResponses, mUrlToResponses; /* * OData batch handler * * @param {string} sServiceBase * the service base URL * @param {object} oRequest * the Sinon request object */ function batch(sServiceBase, oRequest) { var oMultipart = multipart(sServiceBase, oRequest.requestBody), mODataHeaders = getODataHeaders(oRequest); if (fnOnRequest) { fnOnRequest(oRequest.requestBody, oRequest.method + " " + oRequest.url); } oRequest.respond(200, jQuery.extend({}, mODataHeaders, { "Content-Type" : "multipart/mixed;boundary=" + oMultipart.boundary }), formatMultipart(oMultipart, mODataHeaders)); } /* * Builds a responses from <code>mFixture</code>. Reads the source synchronously and * caches it. * @returns {object} a resource object with code, headers, ifMatch and message */ function buildResponse(oFixtureResponse) { var oResponse = { buildResponse : oFixtureResponse.buildResponse, code : oFixtureResponse.code || 200, headers : oFixtureResponse.headers || {}, ifMatch : oFixtureResponse.ifMatch }; if (oFixtureResponse.source) { oResponse.message = readMessage(sBase + oFixtureResponse.source); oResponse.headers["Content-Type"] ||= contentType(oFixtureResponse.source); } else if (typeof oFixtureResponse.message === "object") { oResponse.headers["Content-Type"] = sJson; oResponse.message = JSON.stringify(oFixtureResponse.message); } else { oResponse.message = oFixtureResponse.message; } return oResponse; } /* * Builds the responses from <code>mFixture</code>. Reads the sources synchronously and * caches them. * @returns {map} * a map from "method path" (incl. service URL) to a list of response objects (with * properties code, headers, ifMatch and message) */ function buildResponses() { var oFixtureResponse, sUrl, mUrls = {}; for (sUrl in mFixture) { oFixtureResponse = mFixture[sUrl]; let sMethod = "GET "; const aMatch = rMethod.exec(sUrl); if (aMatch) { sMethod = aMatch[0]; sUrl = sUrl.slice(sMethod.length); } sUrl = sMethod + TestUtils.encodeReadableUrl(sUrl); if (Array.isArray(oFixtureResponse)) { mUrls[sUrl] = oFixtureResponse.map(buildResponse); } else { mUrls[sUrl] = [buildResponse(oFixtureResponse)]; } } return mUrls; } /* * Logs and returns a response for the given error. * @param {number} iCode - The response code * @param {object} oRequest - The request object * @param {string|Error} vMessage - The error * @param {string} [vMessage.target] - The error target * @returns {object} The reponse object */ function error(iCode, oRequest, vMessage) { Log.error(oRequest.method + " " + TestUtils.makeUrlReadable(oRequest.url), vMessage, "sap.ui.test.TestUtils"); return { code : iCode, headers : {"Content-Type" : sJson}, message : JSON.stringify({ error : { code : "TestUtils", message : vMessage instanceof Error ? vMessage.message : vMessage, target : vMessage instanceof Error ? vMessage.target : undefined } }) }; } // returns the first line (containing method and url) function firstLine(sText) { return sText.slice(0, sText.indexOf("\r\n")); } /* * Formats a multipart object into the message body. * * @param {object} oMultipart The multipart object with boundary and parts * @param {map} mODataHeaders The OData headers to copy into the response parts */ function formatMultipart(oMultipart, mODataHeaders) { var aResponseParts = [""]; oMultipart.parts.every(function (oPart) { aResponseParts.push(oPart.boundary ? "\r\nContent-Type: multipart/mixed;boundary=" + oPart.boundary + "\r\n\r\n" + formatMultipart(oPart, mODataHeaders) : formatResponse(oPart, mODataHeaders)); // change set, success response or V2 request (continue on error by default) return !oPart.code || oPart.code < 400 || mODataHeaders.DataServiceVersion === "2.0"; }); aResponseParts.push("--\r\n"); return aResponseParts.join("--" + oMultipart.boundary); } /* * Formats the response to be inserted into the batch * * @param {object} oResponse The response with code, contentId, headers, message * @param {map} mODataHeaders The OData headers from the batch to copy into the response * @returns {string} The response to be inserted into the batch */ function formatResponse(oResponse, mODataHeaders) { var mHeaders = jQuery.extend({}, mODataHeaders, oResponse.headers); // Note: datajs expects a space after the response code return sMimeHeaders + (oResponse.contentId ? "Content-ID: " + oResponse.contentId + "\r\n" : "") + "\r\nHTTP/1.1 " + oResponse.code + " \r\n" + Object.keys(mHeaders).map(function (sHeader) { return sHeader + ": " + mHeaders[sHeader]; }).join("\r\n") + "\r\n\r\n" + (oResponse.message || "") + "\r\n"; } /** * Gets matching responses to the URL and request method from the fixture. First, checks * if a matching response is in the <code>mUrlToResponse</code> map. If that's not the * case, it goes on to check the regular expressions for a match. * @param {string} sMethod The request method * @param {string} sUrl The URL of the request * @returns {object} An object with the properties <code>responses</code> and * <code>match</code> */ function getMatchingResponse(sMethod, sUrl) { var aMatches, aMatchingResponses, sRequestLine = sMethod + " " + sUrl; if (mUrlToResponses[sRequestLine]) { return { responses : mUrlToResponses[sRequestLine] }; } if (!aRegexpResponses) { return undefined; } aMatches = []; aMatchingResponses = aRegexpResponses.filter(function (oResponse) { var aMatch = sRequestLine.match(oResponse.regExp); if (aMatch) { aMatches.push(aMatch); } return aMatch; }); if (aMatchingResponses.length === 2 && sUrl.includes("/$metadata")) { // if there are two matches for a $metadata request, the first one wins aMatches.pop(); aMatchingResponses.pop(); } if (aMatchingResponses.length > 1) { Log.warning("Multiple matches found for " + sRequestLine, undefined, "sap.ui.test.TestUtils"); return undefined; } return aMatchingResponses.length ? { responses : aMatchingResponses[0].response, match : aMatches[0] } : undefined; } /* * Returns a map with only the OData headers that have to be copied to the response * * @param {object} oRequest The request to take the headers from */ function getODataHeaders(oRequest) { var sKey, mODataHeaders = {}; for (sKey in oRequest.requestHeaders) { if (rODataHeaders.test(sKey)) { mODataHeaders[sKey] = oRequest.requestHeaders[sKey]; } } return mODataHeaders; } /* * Determines the matching response for the request. Returns an error response if no * match was found, unless <code>bTry</code> was given. * * @param {object} oRequest The Sinon request object * @param {string} [sContentId] The content ID * @param {boolean} [bTry] * Whether to do nothing and return <code>undefined</code> if no fixture matches; also * prevents defaulting for non-GET requests * @param {object} [mContentId2Response] * A map which refers a content ID to a response message while processing a batch * @returns {object|undefined} The response object or <code>undefined</code> */ function getResponseFromFixture(oRequest, sContentId, bTry, mContentId2Response) { var iAlternative, oMatch = getMatchingResponse(oRequest.method, oRequest.url), oResponse, aResponses = oMatch && oMatch.responses; aResponses = (aResponses || []).filter(function (oResponse0) { if (typeof oResponse0.ifMatch === "function") { return oResponse0.ifMatch(oRequest); } return !oResponse0.ifMatch || oResponse0.ifMatch.test(oRequest.requestBody); }); if (aResponses.length) { oResponse = aResponses[0]; if (typeof oResponse.buildResponse === "function") { oResponse = merge({}, oResponse); try { const aMatches = rContentIdReference.exec(oRequest.requestLine); let sReferencedMessage; if (aMatches) { sReferencedMessage = mContentId2Response[aMatches[1]]; } oResponse.buildResponse(oMatch.match, oResponse, oRequest, sReferencedMessage); } catch (oError) { oResponse = error(500, oRequest, oError); } } if (oMatch.responses.length > 1) { iAlternative = oMatch.responses.indexOf(oResponse); } } else if (!bStrict && !bTry) { switch (oRequest.method) { case "HEAD": oResponse = {code : 200}; break; case "DELETE": case "MERGE": case "PATCH": oResponse = { code : 204 }; break; case "POST": oResponse = { code : 200, headers : {"Content-Type" : sJson}, message : oRequest.requestBody }; break; // no default } } if (oResponse) { const sRequestLine = oRequest.method + " " + TestUtils.makeUrlReadable(oRequest.url); Log.info(sRequestLine + (iAlternative !== undefined ? ", alternative (ifMatch) #" + iAlternative : ""), // Note: JSON.stringify(oRequest.requestHeaders) outputs too much for now '{"If-Match":' + JSON.stringify(oRequest.requestHeaders["If-Match"]) + "}", "sap.ui.test.TestUtils"); if (oResponse.message) { Log.debug(sRequestLine, oResponse.message, "sap.ui.test.TestUtils"); } } else if (bTry) { return undefined; } else { oResponse = error(404, oRequest, "No mock data found"); } oResponse.headers = jQuery.extend({}, getODataHeaders(oRequest), oResponse.headers); if (sContentId && oResponse.code < 300) { oResponse.contentId = sContentId; if (mContentId2Response) { mContentId2Response[sContentId] = oResponse.message; } } return oResponse; } /* * Processes a multipart message (body or change set) * * @param {string} sServiceBase The service base URL * @param {string} sBody The body * @param {object} [oBatch] allows to keep state while processing a batch * @returns {object} An object with the properties boundary and parts */ function multipart(sServiceBase, sBody, oBatch = {}) { // skip preamble consisting of whitespace (as sent by datajs) sBody = sBody.replace(/^\s+/, ""); const sBoundary = firstLine(sBody); return { boundary : firstLine(sBody).slice(2), parts : sBody.split(sBoundary).slice(1, -1).map(function (sRequestPart) { var aFailures, sFirstLine, aMatch, oMultipart, oRequest, iRequestStart; sRequestPart = sRequestPart.slice(2); sFirstLine = firstLine(sRequestPart); if (rMultipartHeader.test(sFirstLine)) { oMultipart = multipart(sServiceBase, sRequestPart.slice(sFirstLine.length + 4), oBatch); aFailures = oMultipart.parts.filter(function (oPart) { return oPart.code >= 300; }); return aFailures.length ? aFailures[0] : oMultipart; } iRequestStart = sRequestPart.indexOf("\r\n\r\n") + 4; oRequest = parseRequest(sServiceBase, sRequestPart.slice(iRequestStart)); aMatch = rContentId.exec(sRequestPart.slice(0, iRequestStart)); return getResponseFromFixture(oRequest, aMatch && aMatch[1], false, oBatch); }) }; } // Parses the request string of a batch into an object matching the Sinon request object function parseRequest(sServiceBase, sRequest) { var iBodySeparator = sRequest.indexOf("\r\n\r\n"), aLines, aMatches, oRequest = {requestHeaders : {}}; oRequest.requestBody = sRequest.slice(iBodySeparator + 4, sRequest.length - 2); sRequest = sRequest.slice(0, iBodySeparator); aLines = sRequest.split("\r\n"); oRequest.requestLine = aLines.shift(); aMatches = rRequestLine.exec(oRequest.requestLine); if (aMatches) { oRequest.method = aMatches[1]; oRequest.url = sServiceBase + aMatches[2]; aLines.forEach(function (sLine) { const aMatches0 = rHeaderLine.exec(sLine); if (aMatches0) { oRequest.requestHeaders[aMatches0[1]] = aMatches0[2]; } }); } return oRequest; } // POST handler which recognizes a $batch function post(oRequest) { var sUrl = oRequest.url; if (rBatch.test(sUrl)) { if (!respondFromFixture(oRequest, true)) { batch(sUrl.slice(0, sUrl.indexOf("/$batch") + 1), oRequest); } } else { respondFromFixture(oRequest); } } /* * Reads and caches the source for the given path. */ function readMessage(sPath) { var sMessage = mMessageForPath[sPath]; if (!sMessage) { jQuery.ajax({ async : false, url : sPath, dataType : "text", success : function (sBody) { sMessage = sBody; } }); if (!sMessage) { throw new Error(sPath + ": resource not found"); } mMessageForPath[sPath] = sMessage; } return sMessage; } /* * Searches the response in the fixture and responds. * * @param {object} oRequest The Sinon request object * @param {boolean} [bTry] * Whether to do nothing and return <code>false</code> if no fixture matches * @returns {boolean} Whether the request was processed */ function respondFromFixture(oRequest, bTry) { var oResponse = getResponseFromFixture(oRequest, undefined, bTry); if (!oResponse) { return false; } if (fnOnRequest) { fnOnRequest(oRequest.requestBody, oRequest.method + " " + oRequest.url); } oRequest.respond(oResponse.code, oResponse.headers, oResponse.message); return true; } function setupServer() { var fnRestore, oServer; // build the fixture mUrlToResponses = buildResponses(); if (aRegExps) { aRegexpResponses = aRegExps.map(function (oRegExpFixture) { return { regExp : oRegExpFixture.regExp, response : Array.isArray(oRegExpFixture.response) ? oRegExpFixture.response.map(buildResponse) : [buildResponse(oRegExpFixture.response)] }; }); } // set up the fake server oServer = sinon.fakeServer.create(); if (oSandbox.getFakes) { // Sinon.JS version >= 5 oSandbox.getFakes().push(oServer); // not a public API of Sinon.JS } else { oSandbox.add(oServer); // not a public API of Sinon.JS } oServer.autoRespond = true; if (sAutoRespondAfter) { oServer.autoRespondAfter = parseInt(sAutoRespondAfter); } // Send all requests except $batch through respondFromFixture oServer.respondWith("GET", /./, respondFromFixture); oServer.respondWith("DELETE", /./, respondFromFixture); oServer.respondWith("HEAD", /./, respondFromFixture); oServer.respondWith("PATCH", /./, respondFromFixture); oServer.respondWith("MERGE", /./, respondFromFixture); oServer.respondWith("POST", /./, post); // wrap oServer.restore to also clear the filter fnRestore = oServer.restore; oServer.restore = function () { sinon.FakeXMLHttpRequest.filters = []; // no API to clear the filter fnRestore.apply(this, arguments); // call the original restore }; // Set up a filter so that other requests (e.g. from jQuery.sap.require) go through. // This filter fetches all DELETE, all POST (incl. $batch) and the selected GET // requests. sinon.xhr.supportsCORS = jQuery.support.cors; sinon.FakeXMLHttpRequest.useFilters = true; sinon.FakeXMLHttpRequest.addFilter(function (sMethod, sUrl) { var bOurs = getMatchingResponse(sMethod, sUrl) || (sServiceUrl ? sUrl.startsWith(sServiceUrl) || rBatch.test(sUrl) : sMethod === "DELETE" || sMethod === "HEAD" || sMethod === "MERGE" || sMethod === "PATCH" || sMethod === "POST" ); // must return true if the request is NOT processed by the fake server return !bOurs; }); return oServer; } // ensure to always search the fake data in test-resources, remove cache buster token sBase = absolutePath(sBase); return setupServer(); }, /** * If a test is wrapped by this function, you can test that locale-dependent texts are * created as expected, but avoid checking against the real message text. The function * ensures that every message retrieved using * <code>sap.ui.getCore().getLibraryResourceBundle().getText()</code> or * <code>sap.ui.core.Lib#getResourceBundle().getText()</code> consists of the key * followed by all parameters referenced in the bundle's text in order of their numbers. * * The function uses <a href="http://sinonjs.org/docs/">Sinon.js</a> and expects that it * has been loaded. It creates a <a href="http://sinonjs.org/docs/#sandbox">Sinon * sandbox</a> which is available as <code>this</code> in the code under test. * * <b>Example</b>: * * In the message bundle a message looks like this: * <pre> * EnterNumber=Enter a number with scale {1} and precision {0}. * </pre> * This leads to the following results: * <table> * <tr><th>Call</th><th>Result</th></tr> * <tr><td><code>getText("EnterNumber", [10])</code></td> * <td>EnterNumber 10 {1}</td></tr> * <tr><td><code>getText("EnterNumber", [10, 3])</code></td> * <td>EnterNumber 10 3</td></tr> * <tr><td><code>getText("EnterNumber", [10, 3, "foo"])</code></td> * <td>EnterNumber 10 3</td></tr> * </table> * * <b>Usage</b>: * <pre> * QUnit.test("parse error", function (assert) { * sap.ui.test.TestUtils.withNormalizedMessages(function () { * var oType = new sap.ui.model.odata.type.Decimal({}, * {constraints : {precision : 10, scale : 3}); * * assert.throws(function () { * oType.parseValue("-123.4567", "string"); * }, /EnterNumber 10 3/); * }); * }); * </pre> * @param {function} fnCodeUnderTest * the code under test * @since 1.27.1 * * @public */ withNormalizedMessages : function (fnCodeUnderTest) { var oSandbox; if (sinon.createSandbox) { oSandbox = sinon.createSandbox(); } else { oSandbox = sinon.sandbox.create(); } try { const fnGetBundle = Library.prototype._loadResourceBundle; oSandbox.stub(Library.prototype, "_loadResourceBundle").callsFake(function () { var oResourceBundle = fnGetBundle.apply(this, [arguments[0], /*sync*/true]); return { getText : function (sKey, aArgs) { var sResult = sKey, sText = oResourceBundle.getText(sKey), i; for (i = 0; i < 10; i += 1) { if (sText.indexOf("{" + i + "}") >= 0) { sResult += " " + (i >= aArgs.length ? "{" + i + "}" : aArgs[i]); } } return sResult; } }; }); fnCodeUnderTest.apply(this); } finally { oSandbox.verifyAndRestore(); } }, /** * @returns {boolean} * <code>true</code> if the real OData service is used. * * @public */ isRealOData : function () { if (sRealOData === "proxy") { throw new Error("realOData=proxy is no longer supported"); } return bRealOData; }, /** * Returns the realOData query parameter so that it can be forwarded to an embedded test * * @returns {string} * the realOData query parameter or "" if none was given * * @public */ getRealOData : function () { return sRealOData ? "&realOData=" + sRealOData : ""; }, /** * Sets a listener function which is called when the fake server responds. The function will * be called with the request body as first parameter and the request HTTP verb plus request * URL as second parameter e.g. "GET /foo/bar". * * Pass <code>null</code> to remove the listener. * * @param {function(string, string)} [fnCallback] - The listener function * * @ui5-restricted sap.ui.model.odata.v4 */ onRequest : function (fnCallback) { fnOnRequest = fnCallback; }, /** * Simply returns <code>sAbsolutePath</code>. * * @param {string} sAbsolutePath * some absolute path * @returns {string} * <code>sAbsolutePath</code> * @deprecated As of version 1.93.0 * This function adjusted the path for the Maven/Java environment. Use a reverse proxy * that forwards this path accordingly. */ proxy : function (sAbsolutePath) { Log.warning("#proxy is no longer supported", null, "sap.ui.test.TestUtils"); return sAbsolutePath; }, /** * Returns the value which has been stored with the given key using {@link #setData} and * resets it. * * @param {string} sKey * The key * @returns {object} * The value * * @public */ retrieveData : function (sKey) { var vValue = mData[sKey]; delete mData[sKey]; return vValue; }, /** * Stores the given value under the given key so that it can be used by a test at a later * point in time. * * @param {string} sKey * The key * @param {object} vValue * The value * * @public */ setData : function (sKey, vValue) { mData[sKey] = vValue; }, /** * Sets up the fake server for OData responses unless real OData responses are requested. * * The behavior is controlled by the request property "realOData". If the property has the * value "direct" or "true", the fake server is <i>not</i> set up. You will need a reverse * proxy to forward the requests to the correct remote server then. * * @param {object} oSandbox * a Sinon sandbox as created using <code>sinon.sandbox.create()</code> * @param {map} mFixture * the fixture for {@link sap.ui.test.TestUtils.useFakeServer}, automatically run through * {@link sap.ui.test.TestUtils.normalizeFixture}. * @param {string} [sSourceBase="sap/ui/core/qunit/odata/v4/data"] * The base path for <code>source</code> values in the fixture. The path must be in the * project's test folder, typically it should start with "sap". * Example: <code>"sap/ui/core/qunit/model"</code> * @param {string} [sFilterBase="/"] * A base path for relative filter URLs in <code>mFixture</code>. * @param {object[]} [aRegExps] * The regular expression array for {@link sap.ui.test.TestUtils.useFakeServer} * * @public * @see #.isRealOData */ setupODataV4Server : function (oSandbox, mFixture, sSourceBase, sFilterBase, aRegExps) { if (this.isRealOData()) { return; } if (!sFilterBase) { sFilterBase = "/"; } else if (sFilterBase.slice(-1) !== "/") { sFilterBase += "/"; } TestUtils.useFakeServer(oSandbox, sSourceBase || "sap/ui/core/qunit/odata/v4/data", TestUtils.normalizeFixture(mFixture, sFilterBase), aRegExps, sFilterBase !== "/" ? sFilterBase : undefined); }, /** * Normalizes the given fixture by adding method "GET" and prefix for relative filter URLs. * * @param {map} mFixture * the fixture for {@link sap.ui.test.TestUtils.useFakeServer}. * @param {string} [sFilterBase="/"] * A base path for relative filter URLs in <code>mFixture</code>. * @returns {map} * The normalized fixture */ normalizeFixture : function (mFixture, sFilterBase) { var mResultingFixture = {}; Object.keys(mFixture).forEach(function (sRequest) { var aMatches = rRequestKey.exec(sRequest), sMethod, sUrl; if (aMatches) { sMethod = aMatches[1] || "GET"; sUrl = aMatches[2]; } else { sMethod = "GET"; sUrl = sRequest; } if (!sUrl.startsWith("/")) { sUrl = sFilterBase + sUrl; } mResultingFixture[sMethod + " " + sUrl] = mFixture[sRequest]; }); return mResultingFixture; }, /** * Creates and returns a spy for <code>XMLHttpRequest.prototype.open</code> which is * used in {@link module:sap/base/util/fetch}. * * @param {object} oSandbox * a Sinon sandbox as created using <code>sinon.sandbox.create()</code> * @return {object} Returns the spy */ spyFetch : function (oSandbox) { var spy = oSandbox.spy(XMLHttpRequest.prototype, "open"); /** * Returns the request URL * @param {number} iCall The 'nth' call * @return {string} Returns the request URL */ spy.calledWithUrl = function (iCall) { return spy.getCall(iCall).args[1]; }; return spy; } }; return TestUtils; }, /* bExport= */ true);