cumulocity-cypress
Version:
Cypress commands for Cumulocity IoT
520 lines (519 loc) • 22.3 kB
JavaScript
const { _ } = Cypress;
import "./../../shared/global";
import { getBaseUrlFromEnv, getCookieAuthFromEnv, normalizedC8yclientArguments, restoreClient, storeClient, throwError, } from "./../utils";
import { BasicAuth, Client, FetchClient, BearerAuth, } from "@c8y/client";
import { wrapFetchRequest, toCypressResponse, throwC8yClientError, isC8yClientError, } from "../../shared/c8yclient";
import { hasAuthentication, isAuthOptions, tenantFromBasicAuth, toC8yAuthentication, } 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,
};
};
// Map to track active request contexts by request ID
const requestContexts = new Map();
// Store current request context for the active c8yclient command
let currentRequestContext = null;
function generateContextId() {
const prefix = Cypress.env("C8Y_CLIENT_REQUEST_ID_PREFIX") || "c8yclnt-";
return `${prefix}${_.uniqueId()}`;
}
function getRequestContext(contextId) {
return requestContexts.get(contextId);
}
_.set(globalThis, "fetchStub", window.fetch);
globalThis.fetch = async function (url, fetchOptions) {
const getMessage = (details) => {
const m = details.method ? `${details.method} ` : "";
let message = `${m}${details.status ?? 0} ${getDisplayUrl(details.url)}`;
if (details.options?.requestId) {
message += ` [${details.options.requestId}]`;
}
if (details.duration) {
message += ` (${details.duration}ms)`;
}
if (details.success != null) {
const statusIcon = details.success ? "✓" : "✗";
message = `${statusIcon} ${message}`;
}
return message;
};
// Use the current request context if available
const ctx = currentRequestContext
? {
...currentRequestContext,
consoleProps: {},
loggedInUser: Cypress.env("C8Y_LOGGED_IN_USER") ??
Cypress.env("C8Y_LOGGED_IN_USER_ALIAS"),
contextId: currentRequestContext.contextId,
startTime: Date.now(),
onRequestStart: (details) => {
if (!currentRequestContext?.logger)
return;
currentRequestContext.logger.set({
message: getMessage(details),
consoleProps: () => ({
"Context ID": details.contextId,
"Request ID": details.options?.requestId || null,
"Request URL": details.url,
"Request Method": details.method,
"Request Headers": details.headers,
"Request Body": details.body,
"Fetch Options": fetchOptions,
...details.additionalInfo,
}),
});
requestContexts.set(details.contextId, currentRequestContext);
},
onRequestEnd: (details) => {
// Update logger if available
if (currentRequestContext?.logger) {
currentRequestContext.logger.set({
message: getMessage(details),
consoleProps: () => ({
"Context ID": details.contextId,
"Request ID": details.options?.requestId || null,
"Request URL": details.url,
"Request Method": details.method,
"Request Headers": fetchOptions?.headers,
"Request Body": fetchOptions?.body,
...(details.error
? { Error: details.error }
: {
"Response Status": details.status ?? 0,
"Response Headers": details.headers ?? {},
"Response Body": details.body ?? null,
}),
Duration: `${details.duration}ms`,
Success: details?.success,
"Fetch Options": fetchOptions,
Options: details.options,
Yielded: details.yielded,
...details.additionalInfo,
}),
});
currentRequestContext.logger.end();
requestContexts.delete(details.contextId);
}
if (details.yielded && currentRequestContext != null) {
currentRequestContext.requests = [
...(currentRequestContext.requests ?? []),
details.yielded,
];
}
},
}
: undefined;
if (currentRequestContext != null) {
requestContexts.set(currentRequestContext.contextId, currentRequestContext);
}
return wrapFetchRequest(url, fetchOptions, ctx);
};
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 = undefined;
let basicAuthArg = undefined;
let cookieAuth = undefined;
let bearerAuth = undefined;
let basicAuth = undefined;
let authFromOptions = false;
if (!isAuthOptions($args[0]) && _.isObject($args[$args.length - 1])) {
const opt = $args[$args.length - 1];
if (opt && opt.auth) {
// explicit auth provided via options has highest priority
authFromOptions = true;
$args = [opt.auth, ...($args[0] === undefined ? $args.slice(1) : $args)];
}
}
else if (!_.isEmpty($args) && isAuthOptions($args[0])) {
authOptions = $args[0];
if (authOptions.user && authOptions.password) {
basicAuthArg = authOptions;
basicAuth = new BasicAuth({
user: authOptions.user,
password: authOptions.password,
tenant: authOptions.tenant,
});
}
if (authOptions.token) {
// use BearerAuth when token is provided via auth options (env or args)
bearerAuth = new BearerAuth(authOptions.token);
}
}
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());
// Select authentication with following precedence:
// 1) Explicit auth from options or previous subject wins over cookie
// 2) CookieAuth (if present) preferred unless preferBasicAuth=true
// 3) Fallback to argAuth (BasicAuth/BearerAuth) if present
const explicitAuth = authFromOptions || prevSubjectIsAuth;
let auth = cookieAuth;
if (options.preferBasicAuth === true && basicAuth) {
auth = basicAuth;
}
else if (bearerAuth &&
(!cookieAuth || Cypress.testingType === "component")) {
auth = bearerAuth;
}
else {
auth = cookieAuth ?? bearerAuth ?? basicAuth;
}
if (explicitAuth && argAuth) {
auth = toC8yAuthentication(argAuth);
}
const baseUrl = options.baseUrl || getBaseUrlFromEnv();
const tenant = (basicAuthArg && tenantFromBasicAuth(basicAuthArg)) ||
(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 && !prevSubjectIsAuth) {
c8yclient = restoreClient() || { _client: undefined };
}
// last fallback option to find authentication
if (!auth && c8yclient._auth) {
auth = c8yclient._auth;
}
// pass userAlias into the auth so it is part of the pact recording
if (authOptions && authOptions.userAlias) {
_.extend(auth, { userAlias: authOptions.userAlias });
}
else if (Cypress.env("C8Y_LOGGED_IN_USER_ALIAS") && auth) {
_.extend(auth, { userAlias: Cypress.env("C8Y_LOGGED_IN_USER_ALIAS") });
}
if (!auth && !hasAuthentication(c8yclient)) {
throwError("Missing authentication. Authentication required.");
}
if (!c8yclient._client && !tenant && !options.skipClientAuthentication) {
if (auth) {
authenticateClient(auth, options, baseUrl).then({ timeout: options.timeout }, (c) => {
return runClient(c, clientFn, prevSubject, baseUrl);
});
}
else {
throwError("Missing authentication. Authentication required.");
}
}
else {
if (!c8yclient._client) {
if (!auth) {
throwError("Missing authentication. Authentication required.");
}
c8yclient._client = new Client(auth, baseUrl);
if (tenant) {
c8yclient._client.core.tenant = tenant;
}
}
else if ((auth && !options.client) || prevSubjectIsAuth) {
if (!auth) {
throwError("Missing authentication. Authentication required.");
}
// 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 });
}
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 () => {
// Generate request ID and set up logging
const contextId = generateContextId();
let logger;
if (options.log !== false) {
logger = Cypress.log({
name: "c8yclient",
autoEnd: false,
message: `Preparing request [${contextId}]`,
consoleProps: () => ({
"contextId ID": contextId,
Options: options,
"Base URL": baseUrl,
"Previous Subject": prevSubject,
}),
});
}
const enabled = Cypress.c8ypact.isEnabled();
const ignore = options?.ignorePact === true || false;
const savePact = !ignore && Cypress.c8ypact.isRecordingEnabled();
// Set up the request context for the global fetch override (always, even when logging is disabled)
currentRequestContext = {
contextId,
logger,
options,
startTime: Date.now(),
client,
savePact,
ignorePact: ignore,
};
const matchPact = (response, schema) => {
const shouldMatchObject = !ignore && enabled && Cypress.c8ypact.mode() === "apply";
const instanceMatcher = Cypress.c8ypact.matcher;
const matchSchemaAndObject = options?.matchSchemaAndObject ??
instanceMatcher?.options?.matchSchemaAndObject ??
Cypress.c8ypact.getConfigValue("matchSchemaAndObject", false);
// Nothing to do when there is no schema and object matching is not active
if (!schema && !shouldMatchObject)
return;
const responses = _.isArray(response) ? response : [response];
// Object matching runs when: no schema provided, OR schema+matchSchemaAndObject is set
const doObjectMatch = shouldMatchObject && (!schema || matchSchemaAndObject);
const info = Cypress.c8ypact.current?.info;
for (const r of responses) {
const record = options.record ?? Cypress.c8ypact.current?.nextRecord(options?.requestId);
// Schema matching: validate each individual response against the schema
if (schema) {
cy.c8ymatch(r, schema, undefined, options);
}
// Object matching: validate each response against the loaded pact record.
// options.record is a static override; nextRecord() is called per-response
// to correctly advance the pact cursor for each item in an array response.
if (doObjectMatch) {
if (record != null && info != null) {
cy.c8ymatch(r, record, info, options);
}
else if (record == null &&
Cypress.c8ypact.getConfigValue("failOnMissingPacts", true)) {
if (Cypress.c8ypact.current == null ||
_.isEmpty(Cypress.c8ypact.current?.records)) {
throwError(`Invalid pact or no records found in pact with id '${Cypress.c8ypact.getCurrentTestId()}'. Check pact file for errors. Disable Cypress.c8ypact.config.failOnMissingPacts to ignore.`);
}
else {
const current = Cypress.c8ypact.current;
const index = _.isFunction(current?.currentRecordIndex)
? (current?.currentRecordIndex() ?? 0)
: 0;
throwError(`Record with index ${index} not found in pact with id '${Cypress.c8ypact.getCurrentTestId()}'. 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) {
// Check if this is a network error (TypeError) rather than an HTTP error response
if (_.isError(error)) {
if (isC8yClientError(error))
throw error;
throwC8yClientError(error, undefined, getRequestContext(contextId));
}
result = error;
}
const cypressResponse = toCypressResponse(result);
if (cypressResponse) {
cypressResponse.$body = options.schema;
if (savePact) {
if (_.isArray(currentRequestContext?.requests)) {
// the last request is cypressResponse, only store other requests first
currentRequestContext.requests.pop();
currentRequestContext.requests.forEach(async (req) => {
await Cypress.c8ypact.savePact(req, client);
});
}
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(() => {
currentRequestContext = null;
if (isArrayOfFunctions(fns) && !_.isEmpty(fns)) {
run(client, fns, response, options, baseUrl);
}
else {
cy.wrap(response, { log: Cypress.c8ypact.debugLog });
}
});
}
catch (err) {
if (_.isError(err)) {
currentRequestContext = null;
throw err;
}
matchPact(err, options.schema);
cy.then(() => {
currentRequestContext = null;
// @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;
}
/**
* Gets a display-friendly URL string, removing the baseUrl for better readability in logs.
*/
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)));
}