cumulocity-cypress
Version:
Cypress commands for Cumulocity IoT
368 lines (367 loc) • 14.3 kB
JavaScript
const { _ } = Cypress;
import { getBaseUrlFromEnv, getCookieAuthFromEnv, normalizedC8yclientArguments, restoreClient, storeClient, tenantFromBasicAuth, throwError, } from "./../utils";
import { BasicAuth, Client, FetchClient, } from "@c8y/client";
import { wrapFetchRequest, toCypressResponse, } from "../../shared/c8yclient";
import { isAuthOptions } from "../../shared/auth";
import "../pact/c8ymatch";
export const defaultClientOptions = () => {
return {
log: true,
timeout: Cypress.env("C8Y_C8YCLIENT_TIMEOUT") || Cypress.config().responseTimeout,
failOnStatusCode: true,
preferBasicAuth: false,
skipClientAuthentication: false,
ignorePact: false,
failOnPactValidation: true,
schema: undefined,
strictMatching: false,
};
};
let logOnce = true;
_.set(globalThis, "fetchStub", window.fetch);
globalThis.fetch = async function (url, fetchOptions) {
const consoleProps = {};
let logger = undefined;
if (logOnce === true) {
logger = Cypress.log({
name: "c8yclient",
autoEnd: false,
message: "",
consoleProps: () => consoleProps,
renderProps() {
function getIndicator() {
if (!consoleProps["Yielded"])
return "pending";
if (consoleProps["Yielded"].isOkStatusCode)
return "successful";
return "bad";
}
function getStatus() {
return ((consoleProps["Yielded"] &&
!_.isEmpty(consoleProps["Yielded"]) &&
consoleProps["Yielded"].status) ||
"---");
}
return {
message: `${fetchOptions?.method || "GET"} ${getStatus()} ${getDisplayUrl(url || consoleProps["Yielded"]?.url || "")}`,
indicator: getIndicator(),
status: getStatus(),
};
},
});
}
else {
logOnce = true;
}
return wrapFetchRequest(url, fetchOptions, {
consoleProps,
logger,
loggedInUser: Cypress.env("C8Y_LOGGED_IN_USER") ??
Cypress.env("C8Y_LOGGED_IN_USER_ALIAS"),
});
};
const c8yclientFn = (...args) => {
const prevSubjectIsAuth = args && !_.isEmpty(args) && isAuthOptions(args[0]);
const prevSubject = args && !_.isEmpty(args) && !isAuthOptions(args[0]) ? args[0] : undefined;
let $args = normalizedC8yclientArguments(args && prevSubject ? args.slice(1) : args);
let authOptions;
let basicAuth, cookieAuth;
if (!isAuthOptions($args[0]) && _.isObject($args[$args.length - 1])) {
$args = [
$args[$args.length - 1].auth,
...($args[0] === undefined ? $args.slice(1) : $args),
];
if ($args[0]?.user) {
basicAuth = $args[0];
}
else {
cookieAuth = $args[0];
}
}
else if (!_.isEmpty($args) && $args[0]?.user) {
authOptions = $args[0];
basicAuth = new BasicAuth({
user: authOptions.user,
password: authOptions.password,
tenant: authOptions.tenant,
});
$args[0] = basicAuth;
}
else if (_.isFunction($args[0]) || isArrayOfFunctions($args[0])) {
$args.unshift(undefined);
}
// check if there is a XSRF token to use for CookieAuth
if (!cookieAuth) {
cookieAuth = getCookieAuthFromEnv();
}
if ($args.length === 2 &&
_.isObject($args[0]) &&
(_.isFunction($args[1]) || isArrayOfFunctions($args[1]))) {
$args.push({});
}
const [argAuth, clientFn, argOptions] = $args;
const options = _.defaults(argOptions, defaultClientOptions());
// force CookieAuth over BasicAuth if present and not disabled by options
const auth = cookieAuth && options.preferBasicAuth === false && !prevSubjectIsAuth
? cookieAuth
: argAuth;
const baseUrl = options.baseUrl || getBaseUrlFromEnv();
const tenant = (basicAuth && tenantFromBasicAuth(basicAuth)) ||
(authOptions && authOptions.tenant) ||
Cypress.env("C8Y_TENANT");
// if client is provided via options, use it
let c8yclient = { _client: options.client };
// restore client only if client is undefined and no auth is provided as previousSubject
// previousSubject must have priority
if (!options.client && !(args[0] && isAuthOptions(args[0]))) {
c8yclient = restoreClient() || { _client: undefined };
}
if (!c8yclient._client && clientFn && !auth) {
throwError("Missing authentication. Authentication or Client required.");
}
// pass userAlias into the auth so it is part of the pact recording
if (authOptions && authOptions.userAlias) {
auth.userAlias = authOptions.userAlias;
}
else if (Cypress.env("C8Y_LOGGED_IN_USER_ALIAS")) {
auth.userAlias = Cypress.env("C8Y_LOGGED_IN_USER_ALIAS");
}
if (!c8yclient._client && !tenant && !options.skipClientAuthentication) {
logOnce = options.log;
authenticateClient(auth, options, baseUrl).then({ timeout: options.timeout }, (c) => {
return runClient(c, clientFn, prevSubject, baseUrl);
});
}
else {
if (!c8yclient._client) {
c8yclient._client = new Client(auth, baseUrl);
if (tenant) {
c8yclient._client.core.tenant = tenant;
}
}
else if ((auth && !options.client) || prevSubjectIsAuth) {
// overwrite auth for restored clients
c8yclient._client.setAuth(auth);
c8yclient._auth = auth;
}
c8yclient._options = options;
if (!c8yclient._auth) {
c8yclient._auth = auth;
}
runClient(c8yclient, clientFn, prevSubject, baseUrl);
}
};
function runClient(client, fns, prevSubject, baseUrl) {
storeClient(client);
if (!fns) {
// return Cypress.isCy(client) ? client : cy.wrap(client._client, { log: false });
return cy.wrap(client._client, { log: false });
}
logOnce = client._options?.log || true;
return run(client, fns, prevSubject, client._options || {}, baseUrl);
}
// create client as Client.authenticate() does, but also support
// Cookie authentication as Client.authenticate() only works with BasicAuth
function authenticateClient(auth, options, baseUrl) {
return cy.then({ timeout: options.timeout }, async () => {
let res;
try {
const clientCore = new FetchClient(auth, baseUrl);
res = await clientCore.fetch("/tenant/currentTenant");
}
catch (error) {
if (_.isError(error)) {
error.name = "CypressError";
throw error;
}
else {
const ee = new Error(`Failed to fetch /tenant/currentTenant`);
ee.name = "CypressError";
throw ee;
}
}
if (res.status !== 200) {
throwError(makeErrorMessage(res.responseObj));
}
const { name } = await res.json();
const client = new Client(auth, baseUrl);
client.core.tenant = name;
return { _client: client, _options: options, _auth: auth };
});
}
function run(client, fns, prevSubject, options, baseUrl) {
const clientFn = isArrayOfFunctions(fns) ? fns.shift() : fns;
if (!clientFn) {
return;
}
const safeClient = client._client;
if (!safeClient) {
throwError("Client not initialized when running client function.");
}
return cy.then({ timeout: options.timeout }, async () => {
const enabled = Cypress.c8ypact.isEnabled();
const ignore = options?.ignorePact === true || false;
const savePact = !ignore && Cypress.c8ypact.isRecordingEnabled();
const matchPact = (response, schema) => {
if (schema) {
cy.c8ymatch(response, schema, undefined, options);
}
else {
// object matching against existing pact
if (ignore || !enabled)
return;
if (Cypress.c8ypact.mode() !== "apply")
return;
for (const r of _.isArray(response) ? response : [response]) {
const record = options.record ?? Cypress.c8ypact.current?.nextRecord();
const info = Cypress.c8ypact.current?.info;
if (record != null && info != null && !ignore) {
cy.c8ymatch(r, record, info, options);
}
else {
if (record == null &&
Cypress.c8ypact.getConfigValue("failOnMissingPacts", true) &&
!ignore) {
throwError(`${Cypress.c8ypact.getCurrentTestId()} not found. Disable Cypress.c8ypact.config.failOnMissingPacts to ignore.`);
}
}
}
}
};
try {
const response = await new Cypress.Promise(async (resolve, reject) => {
const isErrorResponse = (resp) => {
return ((_.isArray(resp) ? resp : [resp]).filter((r) => (r.isOkStatusCode !== true && options.failOnStatusCode) ||
_.isError(r)).length > 0);
};
const preprocessedResponse = async (promise) => {
let result;
try {
result = await promise;
}
catch (error) {
result = error;
}
const cypressResponse = toCypressResponse(result);
if (cypressResponse) {
cypressResponse.$body = options.schema;
if (savePact) {
await Cypress.c8ypact.savePact(cypressResponse, client);
}
if (isErrorResponse(cypressResponse)) {
throw cypressResponse;
}
}
return cypressResponse;
};
const resultPromise = clientFn(safeClient, prevSubject);
if (_.isError(resultPromise)) {
reject(resultPromise);
return;
}
if (_.isArray(resultPromise)) {
let toReject = false;
const result = [];
for (const task of resultPromise) {
try {
result.push(await preprocessedResponse(task));
}
catch (err) {
result.push(err);
toReject = true;
}
}
if (toReject) {
reject(result);
}
else {
resolve(result);
}
}
else {
try {
resolve(await preprocessedResponse(resultPromise));
}
catch (err) {
reject(err);
}
}
});
matchPact(response, options.schema);
cy.then(() => {
if (isArrayOfFunctions(fns) && !_.isEmpty(fns)) {
run(client, fns, response, options, baseUrl);
}
else {
cy.wrap(response, { log: Cypress.c8ypact.debugLog });
}
});
}
catch (err) {
if (_.isError(err))
throw err;
matchPact(err, options.schema);
cy.then(() => {
// @ts-expect-error: utils is not public
Cypress.utils.throwErrByPath("request.c8yclient_status_invalid", {
args: err,
stack: false,
});
});
}
});
}
_.extend(Cypress.errorMessages.request, {
c8yclient_status_invalid(obj) {
const err = obj.args || obj.errorProps || obj;
return {
message: makeErrorMessage(obj),
docsUrl: `${(err.body && err.body.info) ||
"https://github.com/Cumulocity-IoT/cumulocity-cypress"}`,
};
},
});
function makeErrorMessage(obj) {
const err = obj.args || obj.errorProps || obj;
const body = err.body || {};
const message = [
`c8yclient failed with: ${err.status} (${err.statusText})`,
`${err.url}`,
`The response we received from Cumulocity was: `,
`${_.isObject(body) ? JSON.stringify(body, null, 2) : body.toString()}`,
`For more information check:`,
`${(err.body && err.body.info) ||
"https://github.com/Cumulocity-IoT/cumulocity-cypress"}`,
`\n`,
].join(`\n`);
return message;
}
// from error_utils.ts
function getDisplayUrl(url, baseUrl = getBaseUrlFromEnv()) {
if (!baseUrl)
return url;
return url.replace(baseUrl, "");
}
Cypress.Commands.add("c8yclientf", { prevSubject: "optional" }, (...args) => {
const failOnStatus = { failOnStatusCode: false };
args = _.dropRightWhile(args, (n) => n == null);
let options = _.last(args);
if (!_.isObjectLike(options)) {
options = failOnStatus;
args.push(options);
}
else {
args[args.length - 1] = { ...options, ...failOnStatus };
}
return c8yclientFn(...args);
});
Cypress.Commands.add("c8yclient", { prevSubject: "optional" }, c8yclientFn);
/**
* Checks if the given object is an array only containing functions.
* @param obj The object to check.
*/
export function isArrayOfFunctions(functions) {
if (!functions || !_.isArray(functions) || _.isEmpty(functions))
return false;
return _.isEmpty(functions.filter((f) => !_.isFunction(f)));
}