@globisnv/cypress-core 2.3.1 → 2.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/biome.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
- "$schema": "https://biomejs.dev/schemas/2.5.15/schema.json",
2
+ "$schema": "https://biomejs.dev/schemas/2.6.3/schema.json",
3
3
  "vcs": {
4
4
  "enabled": true,
5
5
  "clientKind": "git",
6
6
  "useIgnoreFile": true
7
7
  },
8
8
  "files": {
9
- "includes": ["**", "!dist", "!template"]
9
+ "includes": ["**", "!dist", "!template", "!**/*.md", "!**/*.yml"]
10
10
  },
11
11
  "formatter": {
12
12
  "enabled": true,
@@ -5,6 +5,6 @@ export const GLO_enterCommandlist = (obj) => {
5
5
  cy.GLO_addDate(input, [formName, 'valid_from'], obj.validFrom);
6
6
  cy.GLO_addDate(input, [formName, 'valid_till'], obj.validTo);
7
7
  cy.GLO_checkbox([formName, 'allow_automatic_update'], obj.allowAutomaticUpdate);
8
- cy.GLO_popupWindow([formName, 'btn_select_relation'], obj.customerRelation);
8
+ cy.GLO_popupWindow([formName, 'relation_id'], obj.customerRelation);
9
9
  cy.GLO_addInput([formName, 'commandlist_code'], obj.commandlistCode);
10
10
  };
@@ -56,6 +56,8 @@ export type objConsignment = {
56
56
  etd?: string;
57
57
  eta?: string;
58
58
  fileReference?: string;
59
+ loadStartRequested?: string;
60
+ unloadStartRequested?: string;
59
61
  };
60
62
  export declare const GLO_enterConsignment: (obj: objConsignment, formName?: string, formNameGeneral?: string, formNameSea?: string, formNameWarehouse?: string) => void;
61
63
  export declare const GLO_enterConsignment$Terminal: (obj: objConsignment) => void;
@@ -1,4 +1,4 @@
1
- import { close, equal, input, open } from '../tsHelper.js';
1
+ import { close, equal, equalBool, input, open, typeSearch } from '../tsHelper.js';
2
2
  import { GLO_enterAddressPopup } from './GLO_addressPopup.js';
3
3
  const defaultFormName = 'ng$consignment_dtl';
4
4
  const defaultFormNameGeneral = 'ng$consignment_dtl_general';
@@ -29,7 +29,14 @@ export const GLO_enterConsignment = (obj, formName = defaultFormName, formNameGe
29
29
  cy.GLO_addInput([formName, 'equipment_type_id'], obj.equipmentType);
30
30
  cy.GLO_checkLoadingIndicator();
31
31
  }
32
- if (obj.collectionAddress || obj.deliveryAddress || obj.loadStartBooked || obj.loadEndBooked || obj.loadStartBookedTime || obj.loadEndBookedTime) {
32
+ if (obj.collectionAddress ||
33
+ obj.deliveryAddress ||
34
+ obj.loadStartBooked ||
35
+ obj.loadEndBooked ||
36
+ obj.loadStartBookedTime ||
37
+ obj.loadEndBookedTime ||
38
+ obj.loadStartRequested ||
39
+ obj.unloadStartRequested) {
33
40
  cy.GLO_selectTab([formName, 'tabs_70'], 'General', open);
34
41
  if (obj.collectionAddress) {
35
42
  cy.GLO_clickButton([formNameGeneral, 'btn_select_from_address'], 'fa-search-plus');
@@ -43,6 +50,8 @@ export const GLO_enterConsignment = (obj, formName = defaultFormName, formNameGe
43
50
  obj.deliveryAddress.type ??= 'search';
44
51
  GLO_enterAddressPopup(obj.deliveryAddress);
45
52
  }
53
+ cy.GLO_addDate(input, [formNameGeneral, 'load_start_requested'], obj.loadStartRequested);
54
+ cy.GLO_addDate(input, [formNameGeneral, 'unload_start_requested'], obj.unloadStartRequested);
46
55
  cy.GLO_addDate(input, [formNameGeneral, 'load_start_booked'], obj.loadStartBooked);
47
56
  cy.GLO_addDate(input, [formNameGeneral, 'load_start_booked_time'], obj.loadStartBookedTime);
48
57
  cy.GLO_addDate(input, [formNameGeneral, 'load_end_booked'], obj.loadEndBooked);
@@ -87,8 +96,8 @@ const seaTab = (obj, formName, formNameSea) => {
87
96
  cy.GLO_addInput([formNameSea, 'txt_shipper_reference'], obj.shipperRef);
88
97
  cy.GLO_addInput([formNameSea, 'supplier_incoterm'], obj.shipperIncoTerm);
89
98
  cy.GLO_addInput([formNameSea, 'quay_code'], obj.quayCode);
90
- cy.GLO_addInput([formNameSea, 'from_logistic_location'], obj.POL);
91
- cy.GLO_addInput([formNameSea, 'to_logistic_location'], obj.POD);
99
+ cy.GLO_popupWindow([formNameSea, 'from_logistic_location'], obj.POL, undefined, equalBool, typeSearch);
100
+ cy.GLO_popupWindow([formNameSea, 'to_logistic_location'], obj.POD, undefined, equalBool, typeSearch);
92
101
  cy.GLO_addInput([formNameSea, 'transport_method'], obj.transportMethod);
93
102
  cy.GLO_addDate(input, [formNameSea, 'atd'], obj.atd);
94
103
  cy.GLO_addDate(input, [formNameSea, 'goods_closing'], obj.goodsClosing);
@@ -12,6 +12,8 @@ export const GLO_enterPriceRequestLine$Terminal = (obj) => {
12
12
  cy.GLO_addInput([qtFormName, 'gross_price'], obj.grossPriceTwo);
13
13
  cy.GLO_addInput([qtFormName, 'purchase_costs_euro'], obj.purchaseCostTwo);
14
14
  cy.GLO_addInput([qtFormName, 'discount_amount'], obj.discountTwoValue);
15
+ if (obj.discountTwoValue)
16
+ cy.GLO_checkLoadingIndicator();
15
17
  cy.GLO_addInput([qtFormName, 'discount_percentage'], obj.discountTwoPercentage);
16
18
  });
17
19
  };
@@ -11,6 +11,11 @@ const enterQuotationLine = (useNew, obj) => {
11
11
  const formNameInnerBreak = 'ng$quotationlines_dtl_quantity';
12
12
  const articleSelector = useNew ? 'article_code' : 'btn_select_article';
13
13
  cy.GLO_popupWindow([formName, articleSelector], obj.articleCode);
14
+ // The article selection triggers a double load, so wait until the field really holds the article
15
+ // A falsy article code leaves the current article untouched, so there is nothing to wait for
16
+ if (obj.articleCode) {
17
+ cy.GLO_checkField([formName, 'article_code'], obj.articleCode, undefined, 300000);
18
+ }
14
19
  if (obj.quantityBreaks && obj.quantityBreaks.length > 0) {
15
20
  cy.GLO_selectTab([formName, 'tabs_470'], 'Quantity breaks', open);
16
21
  obj.quantityBreaks.forEach((qtyBreak, index) => {
@@ -167,7 +167,7 @@ Cypress.Commands.add('FOB_showGridColumn', (column, shouldShow) => {
167
167
  else {
168
168
  $checkbox = $sidebar
169
169
  .find('.ag-column-select-column-label')
170
- .filter((_, el) => new RegExp(`^${column}$`, 'i').test(el.innerText))
170
+ .filter((_, el) => new RegExp(`^${column}$`, 'i').test(el.textContent ?? ''))
171
171
  .closest('.ag-column-select-column')
172
172
  .find('input.ag-checkbox-input');
173
173
  }
@@ -570,9 +570,11 @@ declare global {
570
570
  */
571
571
  GLO_clickButtonInGrid(buttonColumn: string, rowFilter?: string | number, validationColumn?: string, x?: number, y?: number): Chainable<JQuery>;
572
572
  /**
573
- * Command to login using Okta, like GLO_oktaLogin, but with the simple addition of a boolean parameter to switch between 2 different accounts.
573
+ * Log out and continue as the default (username) or the alternate (username2) SSO user, Okta or Keycloak.
574
574
  *
575
- * This landing page is not the same as the initial portal. It can only be used when switching users after logging out from a Globis environment.
575
+ * The login is stateless: the logout ends only the app session and the IdP signs the app straight back in.
576
+ * Returning to the user that holds the IdP session is therefore only a logout. Switching to the other user
577
+ * clears the IdP session and logs in on the IdP page.
576
578
  *
577
579
  * @param {usernameFlag} [altUser] - Switch to decide whether a secondary (alternateUsername) account has to be logged into or (defaultUsername) the primary account.
578
580
  * @param {string} [company] - Name of a company to switch to after successfully logging in.
@@ -610,6 +612,36 @@ declare global {
610
612
  * @param {number} [interval] - use this TOGETHER with timeOut for slow-running updates (e.g. batch process) (default: 15s)
611
613
  */
612
614
  GLO_validateGridOrFail(waitValue: string, failValue: string, rowNumber?: number, columnId?: string, timeOut?: number, interval?: number): void;
615
+ /**
616
+ * Command to wait until a field shows a value, and to fail instantly when it shows a fail value.
617
+ * The field counterpart of GLO_validateGridOrFail, e.g. for a status that is updated by a running process.
618
+ * The displayed value is checked, as in GLO_checkField (the text of a valuelist, not its stored code).
619
+ *
620
+ * @param {selectorType} selector - [formName, elementName] of the field.
621
+ * @param {string} waitValue - The value to wait for.
622
+ * @param {string | string[]} failValue - The value(s) that stop the waiting and fail instantly.
623
+ * @param {number} [timeOut] - how long it should wait for the field to update, default: 10m
624
+ * @param {number} [interval] - how often the field is checked, default: 15s
625
+ * @example - cy.GLO_validateFieldOrFail(['ng$billing_task_dtl', 'status'], 'Finished', ['Error', 'Finished with error'], 300000, 2000);
626
+ */
627
+ GLO_validateFieldOrFail(selector: selectorType, waitValue: string, failValue: string | string[], timeOut?: number, interval?: number): void;
628
+ /**
629
+ * Executes a batch processing type and waits until the run it started has finished.
630
+ * The last run of the type is remembered before Execute, so a run that finished earlier is never taken for
631
+ * the new one (row 0 of the 'Batch processing' tab is still the previous run right after Execute).
632
+ * The command opens 'Batch processing type' itself and closes it again when the run has finished.
633
+ *
634
+ * @param {string} batchType - The value to filter the batch processing type list on.
635
+ * @param {string} [columnId='description'] - The column of the batch processing type list to filter on (e.g. 'description', 'transaction_type_text', 'batch_module').
636
+ * @param {string | null} [waitValue='Completed'] - The status the new run must end with. Use null to only wait until the run has finished, whatever its status,
637
+ * e.g. for a batch that processes the records of the whole environment, where one record of a parallel test can turn the run into Error.
638
+ * @param {string} [failValue='Error'] - The status that stops the waiting and fails instantly (not used when waitValue is null).
639
+ * @param {number} [timeOut] - how long it should wait for the run to finish, default: 20m
640
+ * @param {number} [interval] - how often the 'Batch processing' tab is checked, default: 15s
641
+ * @returns {Chainable<batchRunType>} - Yields the new run (submitted, completed, status, error message).
642
+ * @example - cy.GLO_executeBatchProcess('Batch processor accountancy SALES', 'batch_module', null);
643
+ */
644
+ GLO_executeBatchProcess(batchType: string, columnId?: string, waitValue?: string | null, failValue?: string, timeOut?: number, interval?: number): Chainable<batchRunType>;
613
645
  /**
614
646
  * Store the value(s) of one or more fields into the nested '@saveData' alias:
615
647
  * saveData[formName][elementName] = value
@@ -1,4 +1,4 @@
1
- import { bottom, buttonSearch, check, contains, cypressWebsocketLoader, defaultTimeout, enabled, equal, greaterEqual, gridDropDown, gridInput, gridInputWithoutEnter, gridSelect, hideInCommandLog, input, isInvalid, noEqualBool, noSkipCheck, notEqual, parseLocalizedNumber, row, select, skipCheck, span, top, unCheck } from '../tsHelper.js';
1
+ import { bottom, buttonSearch, check, contains, cypressWebsocketLoader, defaultTimeout, enabled, equal, greaterEqual, gridDropDown, gridInput, gridInputWithoutEnter, gridSelect, hideInCommandLog, input, isInvalid, noEqualBool, noSkipCheck, notEqual, open, parseLocalizedNumber, row, select, skipCheck, span, top, unCheck } from '../tsHelper.js';
2
2
  import { attachWebSocketSpy } from '../websocketDetector.js';
3
3
  //! File-specific helpers
4
4
  const monthNames = ['januari', 'februari', 'maart', 'april', 'mei', 'juni', 'juli', 'augustus', 'september', 'oktober', 'november', 'december'];
@@ -192,43 +192,46 @@ const waitForWebSocketAfterSso = (idp = ssoUrl()) => {
192
192
  cy.task('log', `WebSocket spy confirmed connected, ${win.wsMessages?.messages.length} messages captured so far`);
193
193
  });
194
194
  };
195
- const oktaIdpLogin = () => {
196
- cy.origin(ssoUrl(), () => {
195
+ // userEnv/passwordEnv name the env values to log in with, 'username2'/'password2' for the alternate user
196
+ const oktaIdpLogin = (userEnv = 'username', passwordEnv = 'password') => {
197
+ cy.origin(ssoUrl(), { args: { userEnv, passwordEnv } }, ({ userEnv, passwordEnv }) => {
197
198
  cy.get('.o-form-input', { timeout: 60000 })
198
199
  .find('[autocomplete="username"]')
199
200
  .clear()
200
- .type(Cypress.expose('username'));
201
+ .type(Cypress.expose(userEnv));
201
202
  cy.task('log', 'Filled in username');
202
203
  cy.get('[type="submit"]').click();
203
- cy.env(['password']).then(({ password }) => {
204
+ cy.env([passwordEnv]).then((envVars) => {
204
205
  cy.get('.o-form-input')
205
206
  .find('[type="password"]', { timeout: 20000 })
206
- .type(password, { log: false });
207
+ .type(envVars[passwordEnv], { log: false });
207
208
  });
208
209
  cy.task('log', 'Filled in password');
209
210
  cy.get('[type="submit"]').click();
210
211
  });
211
212
  };
212
- // Keycloak: username page, password page, then a one-time code page when the user has OTP (otpSecret is set)
213
- const keycloakIdpLogin = () => {
213
+ // Keycloak: username page, password page, then a one-time code page when the user has OTP (otpSecret is set).
214
+ // otpSecret belongs to the default user, so the alternate user is logged in without a code.
215
+ const keycloakIdpLogin = (userEnv = 'username', passwordEnv = 'password') => {
214
216
  // The code is made outside cy.origin: a task that waits for a free OTP window times out after 4s inside it.
215
217
  // It is claimed right before the IdP pages, so it is still fresh when typed.
216
218
  cy.env(['otpSecret']).then(({ otpSecret }) => {
217
- if (otpSecret)
218
- cy.task('totp', otpSecret, { log: false, timeout: 200000 }).then((code) => keycloakOrigin(code));
219
+ if (otpSecret && userEnv === 'username') {
220
+ cy.task('totp', otpSecret, { log: false, timeout: 200000 }).then((code) => keycloakOrigin(code, userEnv, passwordEnv));
221
+ }
219
222
  else
220
- keycloakOrigin(undefined);
223
+ keycloakOrigin(undefined, userEnv, passwordEnv);
221
224
  });
222
225
  };
223
- const keycloakOrigin = (code) => {
224
- cy.origin(ssoUrl(), { args: { code } }, ({ code }) => {
226
+ const keycloakOrigin = (code, userEnv, passwordEnv) => {
227
+ cy.origin(ssoUrl(), { args: { code, userEnv, passwordEnv } }, ({ code, userEnv, passwordEnv }) => {
225
228
  cy.get('input[name="username"]', { timeout: 60000 })
226
229
  .clear()
227
- .type(Cypress.expose('username'));
230
+ .type(Cypress.expose(userEnv));
228
231
  cy.task('log', 'Filled in username');
229
232
  cy.get('#kc-login').click();
230
- cy.env(['password']).then(({ password }) => {
231
- cy.get('input[name="password"]', { timeout: 20000 }).type(password, { log: false });
233
+ cy.env([passwordEnv]).then((envVars) => {
234
+ cy.get('input[name="password"]', { timeout: 20000 }).type(envVars[passwordEnv], { log: false });
232
235
  cy.task('log', 'Filled in password');
233
236
  cy.get('#kc-login').click();
234
237
  if (code) {
@@ -240,6 +243,15 @@ const keycloakOrigin = (code) => {
240
243
  });
241
244
  });
242
245
  };
246
+ const idpLogin = (userEnv = 'username', passwordEnv = 'password') => {
247
+ if (ssoProvider() === 'okta')
248
+ oktaIdpLogin(userEnv, passwordEnv);
249
+ else
250
+ keycloakIdpLogin(userEnv, passwordEnv);
251
+ };
252
+ // Whether the IdP session currently belongs to the alternate user (username2), see GLO_oktaAlternativeLogin.
253
+ // ssoLogin restores the default user's IdP session, so it resets this.
254
+ let ssoAlternateUserActive = false;
243
255
  // One IdP login per run (cy.session, also across specs): every later call only revisits the app,
244
256
  // which the IdP signs in silently, so no password/OTP is asked again.
245
257
  // ponytail: no `validate` — an expired IdP session shows up as a login page, add a validate if that happens
@@ -262,10 +274,7 @@ const ssoLogin = (company, urlType, skipvNavCheck) => {
262
274
  // AUT page load (see websocketDetector.ts), which covers this server-side redirect too.
263
275
  cy.visit(url);
264
276
  cy.task('log', `Redirect to ${provider} url`);
265
- if (provider === 'okta')
266
- oktaIdpLogin();
267
- else
268
- keycloakIdpLogin();
277
+ idpLogin();
269
278
  // Let the app finish its login callback, then keep only the IdP cookies: the app's own client session
270
279
  // does not survive a restore, so every call starts a fresh app client that the IdP signs in silently.
271
280
  if (cypressWebsocketLoader)
@@ -292,6 +301,7 @@ const ssoLogin = (company, urlType, skipvNavCheck) => {
292
301
  }
293
302
  });
294
303
  }, { cacheAcrossSpecs: true });
304
+ ssoAlternateUserActive = false;
295
305
  cy.visit(url);
296
306
  if (company) {
297
307
  cy.GLO_selectCompany(company);
@@ -694,8 +704,8 @@ const GLO_inputValuelist = (selector, rawValue) => {
694
704
  cy.get('@before').then(($before) => {
695
705
  getDropdown('.dx-scrollview-content .dx-item', 30000)
696
706
  .should(($current) => {
697
- const initialTexts = [...$before].map((el) => el.innerText.trim());
698
- const currentTexts = [...$current].map((el) => el.innerText.trim());
707
+ const initialTexts = [...$before].map((el) => (el.textContent ?? '').trim());
708
+ const currentTexts = [...$current].map((el) => (el.textContent ?? '').trim());
699
709
  const hasChanged = !Cypress._.isEqual(currentTexts, initialTexts);
700
710
  // Also accept when the list is the same size or larger: branch-filtered valuelists
701
711
  // with few options won't change after clearing because the full list IS the filtered list.
@@ -733,8 +743,8 @@ const GLO_inputValuelist = (selector, rawValue) => {
733
743
  cy.get('@before')
734
744
  .then(($before) => {
735
745
  getDropdown('.dx-scrollview-content .dx-item', 30000).should(($current) => {
736
- const initialTexts = [...$before].map((el) => el.innerText.trim());
737
- const currentTexts = [...$current].map((el) => el.innerText.trim());
746
+ const initialTexts = [...$before].map((el) => (el.textContent ?? '').trim());
747
+ const currentTexts = [...$current].map((el) => (el.textContent ?? '').trim());
738
748
  const hasChanged = !Cypress._.isEqual(currentTexts, initialTexts);
739
749
  const anyIncludes = currentTexts.every((text) => text.includes(dropdownValue));
740
750
  // An empty list is the transitional state while the filtered valuelist is still loading, never a valid result.
@@ -1232,7 +1242,7 @@ Cypress.Commands.add('GLO_selectRowInGrid', (rowFilter) => {
1232
1242
  cy.get('[row-index]')
1233
1243
  .filter(isInOpenContainer)
1234
1244
  .each(($row) => {
1235
- const cellTexts = Array.from($row.find('.ag-cell-value')).map((el) => el.innerText.trim());
1245
+ const cellTexts = Array.from($row.find('.ag-cell-value')).map((el) => (el.textContent ?? '').trim());
1236
1246
  // Check to see if all requested values are present in any of the cells.
1237
1247
  const matches = filters.every((filterValue) => cellTexts.some((val) => val.includes(filterValue)));
1238
1248
  if (matches) {
@@ -1453,7 +1463,8 @@ Cypress.Commands.add('GLO_popupWindow', (selector, value, columnId, equals = noE
1453
1463
  selectFromPopupWindow(value, columnId, equals);
1454
1464
  }
1455
1465
  else {
1456
- cy.GLO_checkField(selector, value, equals ? contains : undefined);
1466
+ // Type search with an exact match must stay exact: a contains check would accept LOC1 for LOC10.
1467
+ cy.GLO_checkField(selector, value, equals ? (useMagnifierFlag ? contains : equal) : undefined);
1457
1468
  }
1458
1469
  });
1459
1470
  });
@@ -1956,6 +1967,9 @@ Cypress.Commands.add('GLO_fillInfieldInGrid', (selector, rowFilter, columnId, va
1956
1967
  cy.GLO_getElement(selector).within(() => {
1957
1968
  cy.GLO_clickRowInGrid(rowFilter, columnId);
1958
1969
  });
1970
+ // Re-check outside the .within(): loading-indicator renders at page level, so the check
1971
+ // inside GLO_clickRowInGrid (scoped to the grid element above) can't see it and resolves early.
1972
+ cy.GLO_checkLoadingIndicator();
1959
1973
  // biome-ignore lint/plugin/noUnnecessaryWaiting: else both click and dblclick fire too fast
1960
1974
  cy.wait(500);
1961
1975
  cy.GLO_dblClickRowInGrid(row, selector, rowFilter, columnId, skipCheck);
@@ -2052,56 +2066,37 @@ Cypress.Commands.add('GLO_clickButtonInGrid', { prevSubject: ['optional', 'eleme
2052
2066
  }
2053
2067
  cy.GLO_checkLoadingIndicator();
2054
2068
  });
2069
+ // Stateless SSO login: the logout only ends the app's Servoy client, the IdP session stays. The app starts a
2070
+ // fresh client right away, which the IdP signs in silently as the user that holds the IdP session, so there is
2071
+ // no IdP login page to fill in. Getting back to that user is just the logout. Another user needs the IdP session
2072
+ // dropped first, so the IdP asks for credentials again.
2055
2073
  Cypress.Commands.add('GLO_oktaAlternativeLogin', (altUser, company) => {
2056
- cy.GLO_logout();
2057
- // biome-ignore lint/plugin/noUnnecessaryWaiting: wait for Okta to process logout
2058
- cy.wait(10000);
2074
+ const userEnv = altUser ? 'username2' : 'username';
2075
+ const passwordEnv = altUser ? 'password2' : 'password';
2076
+ const switchIdpUser = Boolean(altUser) !== ssoAlternateUserActive;
2059
2077
  if (cypressWebsocketLoader) {
2060
2078
  cy.on('window:before:load', (win) => {
2061
2079
  attachWebSocketSpy(win);
2062
2080
  });
2063
2081
  }
2064
- if (altUser) {
2065
- cy.origin(ssoUrl(), () => {
2066
- cy.get('.okta-container', { timeout: 120000 });
2067
- cy.get('.o-form-input', { timeout: 60000 })
2068
- .find('[autocomplete="username"]')
2069
- .clear()
2070
- .type(Cypress.expose('username2'));
2071
- cy.task('log', 'Filled in username');
2072
- cy.get('[type="submit"]').click();
2073
- cy.env(['password2']).then(({ password2 }) => {
2074
- cy.get('.o-form-input')
2075
- .find('[type="password"]', { timeout: 20000 })
2076
- .type(password2, { log: false });
2077
- });
2078
- cy.task('log', 'Filled in password');
2079
- cy.get('[type="submit"]').click();
2080
- });
2081
- cy.GLO_log(`Logged in as ${Cypress.expose('username2')}`);
2082
- }
2083
- else {
2084
- cy.origin(ssoUrl(), () => {
2085
- cy.get('.okta-container', { timeout: 120000 });
2086
- cy.get('.o-form-input', { timeout: 60000 })
2087
- .find('[autocomplete="username"]')
2088
- .clear()
2089
- .type(Cypress.expose('username'));
2090
- cy.task('log', 'Filled in username');
2091
- cy.get('[type="submit"]').click();
2092
- cy.env(['password']).then(({ password }) => {
2093
- cy.get('.o-form-input')
2094
- .find('[type="password"]', { timeout: 20000 })
2095
- .type(password, { log: false });
2096
- });
2097
- cy.task('log', 'Filled in password');
2098
- cy.get('[type="submit"]').click();
2099
- });
2100
- cy.GLO_log(`Logged in as ${Cypress.expose('username')}`);
2101
- }
2082
+ // Come back on the solution the test is in (GLO_login can start another one than globisUrl), without the hash state
2083
+ cy.location({ log: false }).then((location) => {
2084
+ const appUrl = `${location.origin}${location.pathname}${location.search}`;
2085
+ cy.GLO_logout();
2086
+ if (switchIdpUser) {
2087
+ // clearAllCookies clears every domain, the IdP session included (clearCookies would only clear the app's)
2088
+ cy.clearAllCookies({ log: false });
2089
+ cy.clearAllSessionStorage({ log: false }); // the solution start fails on a missing owner_id otherwise
2090
+ cy.visit(appUrl);
2091
+ idpLogin(userEnv, passwordEnv);
2092
+ ssoAlternateUserActive = Boolean(altUser);
2093
+ }
2094
+ });
2102
2095
  if (cypressWebsocketLoader)
2103
2096
  waitForWebSocketAfterSso();
2104
2097
  cy.get('[data-cy$="ng$main_form2.navbar_top"]', { timeout: 120000 }).should('exist');
2098
+ cy.GLO_checkLoadingIndicator();
2099
+ cy.GLO_log(`Logged in as ${Cypress.expose(userEnv)}`);
2105
2100
  if (company)
2106
2101
  cy.GLO_checkAndSelectCompany(company);
2107
2102
  });
@@ -2124,6 +2119,120 @@ Cypress.Commands.add('GLO_getAllRowsInGrid', (selector, timeOut = defaultTimeout
2124
2119
  Cypress.Commands.add('GLO_validateGridOrFail', (waitValue, failValue, rowNumber, columnId, timeOut = 600000, interval = 15000) => {
2125
2120
  validateGridValue(waitValue, failValue, rowNumber, columnId, timeOut, interval);
2126
2121
  });
2122
+ Cypress.Commands.add('GLO_validateFieldOrFail', (selector, waitValue, failValue, timeOut = 600000, interval = 15000) => {
2123
+ const failValues = Array.isArray(failValue) ? failValue : [failValue];
2124
+ const maxAttempts = Math.max(1, Math.ceil(timeOut / interval));
2125
+ const fieldName = selector.join('.');
2126
+ // A trailing index picks a grid row or, on a field with several inputs, the input: same as GLO_checkField
2127
+ const { baseSelector, index } = extractSelectorParts(selector);
2128
+ const checkField = (attempt = 1) => {
2129
+ // Decided on every attempt and only once the field is in the DOM: a grid that is still rendering would
2130
+ // otherwise be taken for a field with several inputs, and that choice would stick for the whole poll.
2131
+ // A grid row is then looked up with the full selector, so GLO_getElement waits for that row itself.
2132
+ cy.GLO_getElement(baseSelector).then(() => {
2133
+ const inGrid = isGridRowSelector(baseSelector, index);
2134
+ const effectiveIndex = inGrid ? 0 : index;
2135
+ // Same value as GLO_checkField reads: the displayed text, with html and other unicode spaces normalized
2136
+ cy.GLO_getElement(inGrid ? selector : baseSelector).then(($field) => {
2137
+ const fieldValue = String($field.find('.dx-texteditor-input').eq(effectiveIndex).val()).replace(/\s+/g, ' ').trim();
2138
+ if (fieldValue === waitValue) {
2139
+ cy.log(`${fieldName} is "${waitValue}"`);
2140
+ return;
2141
+ }
2142
+ if (failValues.includes(fieldValue)) {
2143
+ throw new Error(`${fieldName} is "${fieldValue}" (waited for "${waitValue}")`);
2144
+ }
2145
+ if (attempt >= maxAttempts) {
2146
+ throw new Error(`${fieldName} is still "${fieldValue}" after ${timeOut / 1000}s (waited for "${waitValue}")`);
2147
+ }
2148
+ // biome-ignore lint/plugin/noUnnecessaryWaiting: wait for next poll interval before checking again
2149
+ cy.wait(interval, { log: false }).then(() => {
2150
+ checkField(attempt + 1);
2151
+ });
2152
+ });
2153
+ });
2154
+ };
2155
+ checkField();
2156
+ });
2157
+ Cypress.Commands.add('GLO_executeBatchProcess', (batchType, columnId = 'description', waitValue = 'Completed', failValue = 'Error', timeOut = 1200000, interval = 15000) => {
2158
+ const formNameTypeList = 'ng$batch_execution_type_lst';
2159
+ const formNameTypeTab = 'ng$batch_execution_type_tab';
2160
+ const formNameRunList = 'ng$batch_execution_lst';
2161
+ const maxAttempts = Math.max(1, Math.ceil(timeOut / interval));
2162
+ // Row 0 of the run list is the most recent run (sorted on process_submitted desc); an empty list has no run yet.
2163
+ // Without these columns (hidden in a custom view) a new run can never be recognised, so that fails at once.
2164
+ const requiredColumns = ['process_submitted', 'process_completed', 'process_status'];
2165
+ const readLastRun = () => {
2166
+ const noRun = { submitted: '', completed: '', status: '', errorMessage: '' };
2167
+ return cy.GLO_getElement([formNameRunList, 'grid']).then(($grid) => {
2168
+ if ($grid.find(gridDataRows).length === 0) {
2169
+ return cy.wrap(noRun, { log: false });
2170
+ }
2171
+ return cy
2172
+ .wrap($grid, { log: false })
2173
+ .within(() => {
2174
+ cy.GLO_getRowInGrid(0).then(($row) => {
2175
+ const missingColumns = requiredColumns.filter((requiredColumnId) => $row.find(`[col-id="${requiredColumnId}"]`).length === 0);
2176
+ if (missingColumns.length) {
2177
+ throw new Error(`The 'Batch processing' tab of ${batchType} does not show the column(s) ${missingColumns.join(', ')}`);
2178
+ }
2179
+ const cellText = (cellColumnId) => $row.find(`[col-id="${cellColumnId}"]`).text().trim();
2180
+ cy.wrap({
2181
+ submitted: cellText('process_submitted'),
2182
+ completed: cellText('process_completed'),
2183
+ status: cellText('process_status'),
2184
+ errorMessage: cellText('last_error_message')
2185
+ }, { log: false }).as('COM_batchRun');
2186
+ });
2187
+ })
2188
+ .then(() => cy.get('@COM_batchRun', { log: false }));
2189
+ });
2190
+ };
2191
+ // The completion time (process_completed) is stamped for every final status, Completed as well as Error
2192
+ const waitForNewRun = (previousSubmitted, attempt = 1) => {
2193
+ cy.GLO_checkAGLoader();
2194
+ return readLastRun().then((run) => {
2195
+ const isNewRun = run.submitted !== '' && run.submitted !== previousSubmitted;
2196
+ const hasFailed = waitValue !== null && run.status === failValue;
2197
+ if (isNewRun && (run.completed !== '' || hasFailed)) {
2198
+ cy.GLO_log(`Batch run of ${run.submitted} (${batchType}) finished with status "${run.status}" ${run.errorMessage}`);
2199
+ if (hasFailed) {
2200
+ throw new Error(`Batch run of ${run.submitted} (${batchType}) ended with status "${run.status}": ${run.errorMessage}`);
2201
+ }
2202
+ if (waitValue !== null) {
2203
+ expect(run.status, `status of the batch run of ${run.submitted} (${batchType})`).to.equal(waitValue);
2204
+ }
2205
+ return cy.wrap(run, { log: false });
2206
+ }
2207
+ if (attempt >= maxAttempts) {
2208
+ throw new Error(`No finished run of ${batchType} after ${timeOut / 1000}s (last run submitted: ${run.submitted})`);
2209
+ }
2210
+ // biome-ignore lint/plugin/noUnnecessaryWaiting: wait for next poll interval before checking again
2211
+ return cy.wait(interval, { log: false }).then(() => waitForNewRun(previousSubmitted, attempt + 1));
2212
+ });
2213
+ };
2214
+ cy.GLO_searchHeader('Batch processes', 'Batch processes', 'Batch processing type');
2215
+ cy.GLO_clickDatacontrolBrowserButton('btnAll');
2216
+ // A filter typed while the list is still loading is lost
2217
+ cy.GLO_checkAGLoader();
2218
+ // Equals, not contains: 'CALCULATE INVOICE' must not select 'CALCULATE INVOICE RETOUR'
2219
+ cy.GLO_filterGrid(columnId, [formNameTypeList, 'grid'], batchType, undefined, 'Equals');
2220
+ cy.GLO_getElement([formNameTypeList, 'grid']).within(() => {
2221
+ cy.GLO_checkValueGrid(batchType, 0, columnId, undefined, undefined, equal);
2222
+ });
2223
+ cy.GLO_selectTab([formNameTypeTab, 'tabs'], 'Batch processing', open);
2224
+ cy.GLO_checkAGLoader();
2225
+ return readLastRun().then((previousRun) => {
2226
+ cy.GLO_selectMenuButton('Execute');
2227
+ cy.GLO_log(`Executed batch processing type ${batchType}`);
2228
+ return waitForNewRun(previousRun.submitted).then((run) => {
2229
+ // Every call opens the program, so it is closed again: a second instance would keep a hidden run list in
2230
+ // the page, and the caller gets back the programs it had open
2231
+ cy.GLO_closeProgram(top, 'Batch processing type');
2232
+ return cy.wrap(run, { log: false });
2233
+ });
2234
+ });
2235
+ });
2127
2236
  const validateGridValue = (value, failValue, rowFilter, columnId, timeOut = 10000, interval, operator = contains) => {
2128
2237
  const assertOrCheckCellValue = ($cells, toCheck, doAssert) => {
2129
2238
  const compare = (cellText) => {
@@ -2222,21 +2331,22 @@ const validateGridValue = (value, failValue, rowFilter, columnId, timeOut = 1000
2222
2331
  cy.then(() => promise);
2223
2332
  }
2224
2333
  };
2225
- Cypress.Commands.add('GLO_getCurrentRecordCount', () => {
2226
- return cy
2227
- .GLO_getElement(['ng$datacontrol', 'datacontrol'])
2228
- .find('#txtCount')
2229
- .invoke('text')
2230
- .then((records) => {
2231
- const [currentStr, totalStr] = records.split('/');
2232
- if (!currentStr || !totalStr) {
2233
- throw new Error(`Could not parse record count from "${records}"`);
2234
- }
2235
- return {
2236
- currentRecord: parseInt(currentStr.trim(), 10),
2237
- totalRecords: parseInt(totalStr.trim(), 10)
2238
- };
2239
- });
2334
+ Cypress.Commands.addQuery('GLO_getCurrentRecordCount', function (timeOut) {
2335
+ // this.set('timeout', ...) is how the Cypress docs give a custom query its timeout, but the typings leave
2336
+ // 'timeout' out of EnqueuedCommandAttributes
2337
+ if (timeOut !== undefined)
2338
+ this.set('timeout', timeOut);
2339
+ return () => {
2340
+ const $count = Cypress.$('div[formslist]:not([hidden]) [data-cy$="ng$datacontrol.datacontrol"] #txtCount');
2341
+ const text = $count.first().text();
2342
+ const [currentRecord, totalRecords] = text.split('/').map((part) => Number.parseInt(part.trim(), 10));
2343
+ // Throwing makes the query retry: while the datacontrol is still rendering there is no count (or no
2344
+ // '<current>/<total>' text yet), and returning NaN would let the caller go on with an invalid count
2345
+ if ($count.length === 0 || !Number.isInteger(currentRecord) || !Number.isInteger(totalRecords)) {
2346
+ throw new Error(`No record count on the active datacontrol yet (#txtCount: "${text}")`);
2347
+ }
2348
+ return { currentRecord, totalRecords };
2349
+ };
2240
2350
  });
2241
2351
  Cypress.Commands.add('GLO_storeObject', (formName, elementNames, index, log = hideInCommandLog) => {
2242
2352
  const keys = (Array.isArray(elementNames) ? elementNames : [elementNames]);
@@ -14,28 +14,43 @@ Cypress.Commands.add('GLS_login', (username = 'PR001', company) => {
14
14
  cy.GLO_checkLoadingIndicator();
15
15
  });
16
16
  Cypress.Commands.add('GLS_mainNavigation', (value) => {
17
- const scrollAndSearch = (attempts = 0) => {
18
- if (attempts > 20) {
19
- throw new Error(`Could not find menu item: ${value}`);
17
+ // Newer GlobScan versions show the menu as a list form component (VCK-376, 11.0.x), older ones as an AG Grid.
18
+ // Waits for whichever menu is there and uses that one.
19
+ const listMenuItems = '.svy-listformcomponent label.clickable';
20
+ cy.get(`${listMenuItems}, .ag-grid-scrolling-rows`).then(($menu) => {
21
+ if ($menu.is(listMenuItems)) {
22
+ // Exact match, so 'Wegzetten' does not open 'Wegzetten drager'. A menu level that is still mounted but hidden is skipped
23
+ // through its [hidden] container instead of :visible, which also drops items scrolled out of the list (see isInOpenContainer)
24
+ const exactText = new RegExp(`^\\s*${Cypress._.escapeRegExp(value)}\\s*$`);
25
+ cy.get(listMenuItems)
26
+ .filter((_i, el) => !el.closest('[hidden]') && exactText.test(el.textContent ?? ''))
27
+ .click();
28
+ cy.GLO_checkLoadingIndicator();
29
+ return;
20
30
  }
21
- return cy
22
- .get('.ag-grid-scrolling-rows')
23
- .first()
24
- .then(($viewport) => {
25
- const $label = $viewport.find('label').filter((_i, el) => el.innerText.trim() === value);
26
- if ($label.length > 0 && $label.is(':visible')) {
27
- return cy.contains('.ag-grid-scrolling-rows label', value).click();
31
+ const scrollAndSearch = (attempts = 0) => {
32
+ if (attempts > 20) {
33
+ throw new Error(`Could not find menu item: ${value}`);
28
34
  }
29
35
  return cy
30
- .get('.ag-grid-scrolling-container [role="row"][tabindex="0"]')
36
+ .get('.ag-grid-scrolling-rows')
31
37
  .first()
32
- .focus()
33
- .realPress('ArrowDown')
34
- .then(() => scrollAndSearch(attempts + 1));
38
+ .then(($viewport) => {
39
+ const $label = $viewport.find('label').filter((_i, el) => (el.textContent ?? '').trim() === value);
40
+ if ($label.length > 0 && $label.is(':visible')) {
41
+ return cy.contains('.ag-grid-scrolling-rows label', value).click();
42
+ }
43
+ return cy
44
+ .get('.ag-grid-scrolling-container [role="row"][tabindex="0"]')
45
+ .first()
46
+ .focus()
47
+ .realPress('ArrowDown')
48
+ .then(() => scrollAndSearch(attempts + 1));
49
+ });
50
+ };
51
+ scrollAndSearch().then(() => {
52
+ cy.GLO_checkLoadingIndicator();
35
53
  });
36
- };
37
- scrollAndSearch().then(() => {
38
- cy.GLO_checkLoadingIndicator();
39
54
  });
40
55
  });
41
56
  Cypress.Commands.add('GLS_selectStock', (formName, elementName, index) => {
@@ -5,7 +5,7 @@ Cypress.Commands.add('POR_checkPortalSideMenuCorrect', (menuItems) => {
5
5
  .find('.main-items .item-main , .main-items .item-header')
6
6
  .should('have.length.gt', 1)
7
7
  .then((elements) => {
8
- return Cypress.$.makeArray(elements).map((el) => el.innerText);
8
+ return Cypress.$.makeArray(elements).map((el) => (el.textContent ?? '').trim());
9
9
  })
10
10
  .should('deep.eq', menuItems);
11
11
  cy.GLO_checkLoadingIndicator();
package/dist/types.d.ts CHANGED
@@ -13,6 +13,12 @@ type viewModeButtonType = 'btnList' | 'btnDetail';
13
13
  type datacontrolbuttonType = baseDatacontrolViewerButtonType | viewModeButtonType | 'btnPrev' | 'btnNext' | 'btnAll' | 'btnPivot' | 'btnAttachment' | 'btnAddMode' | 'btnEditMode' | 'btnDeleteMode' | 'btnDeeplink' | 'btnExtendedSearch' | 'btnCustomViews' | 'btnFilter' | 'btnClearAllFilters';
14
14
  type datacontrolCounterButtonType = 'btnAttachment' | 'btnNotification';
15
15
  type checkValueInGridType = string | string[] | number;
16
+ type batchRunType = {
17
+ submitted: string;
18
+ completed: string;
19
+ status: string;
20
+ errorMessage: string;
21
+ };
16
22
  type selectMenuButtonLeftType = 'home' | 'settings' | 'help' | 'support' | 'excel' | 'info' | 'refresh' | 'query' | 'clients' | 'users' | 'theme';
17
23
  type urlTypeType = 'online' | 'pos' | 'globgate' | 'globjob' | 'globscan' | 'portal' | 'carrier' | 'globtrace' | 'external_portal' | 'globtake' | 'globis_external_portal';
18
24
  type customValueType = JQuery | string | number;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@globisnv/cypress-core",
3
- "version": "2.3.1",
3
+ "version": "2.4.1",
4
4
  "description": "Core building blocks for Globis Cypress E2E tests",
5
5
  "license": "UNLICENSED",
6
6
  "type": "module",
@@ -59,7 +59,7 @@
59
59
  "devDependencies": {
60
60
  "@biomejs/biome": "^2.5.15",
61
61
  "@types/node": "^26.1.2",
62
- "cypress": "^16.1.0",
62
+ "cypress": "^16.2.1",
63
63
  "cypress-real-events": "^1.15.1",
64
64
  "typescript": "^7.0.2"
65
65
  },