@globisnv/cypress-core 1.1.0 → 1.1.2

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.
@@ -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
  };
@@ -183,6 +183,8 @@ declare global {
183
183
  /**
184
184
  * Command to select a tab using its data-cy attribute, which is composed of formName.elementName
185
185
  *
186
+ * Fails if the tab has to be opened or closed but is disabled.
187
+ *
186
188
  * @param {string[]} selector - One element: `[name=]` lookup. Two elements: `[data-cy$="form.element"]` lookup.
187
189
  * @param {string} tabName - The name of the tab to be selected.
188
190
  * @param {OpenClose} openOrClose - Indicates whether to open (true) or close (false) the tab.
@@ -647,6 +649,14 @@ declare global {
647
649
  * @param {disabledFlag} [shouldBeEnabled=true] - Whether the field should be enabled (true) or disabled (false).
648
650
  */
649
651
  GLO_checkFieldEnabled(selector: string[], shouldBeEnabled?: disabledFlag): void;
652
+ /**
653
+ * Checks if a tab is enabled or disabled, without clicking it.
654
+ *
655
+ * @param {string[]} selector - One element: `[name=]` lookup. Two elements: `[data-cy$="form.element"]` lookup.
656
+ * @param {string} tabName - The name of the tab to check.
657
+ * @param {disabledFlag} [shouldBeEnabled=true] - Whether the tab should be enabled (true) or disabled (false).
658
+ */
659
+ GLO_checkTabEnabled(selector: string[], tabName: string, shouldBeEnabled?: disabledFlag): void;
650
660
  /**
651
661
  * Checks if a field has the given background color class applied to its input element.
652
662
  *
@@ -713,6 +713,12 @@ Cypress.Commands.add('GLO_selectTab', (selector, tabName, openOrClose) => {
713
713
  const isOpenAttr = $tab[0].getAttribute('open'); // Attribute is a string 'true' or missing -> null
714
714
  const isOpen = isOpenAttr === 'true'; // Convert to boolean
715
715
  if (openOrClose !== isOpen) {
716
+ // The tab header is a div, so Cypress's `:disabled` actionability check doesn't apply: the click goes through
717
+ // and the server silently ignores it. Closing a disabled tab that is open fails too, because the close request
718
+ // goes through the same click on the same disabled tab, which the server ignores the same way.
719
+ if ($tab[0].hasAttribute('disabled')) {
720
+ throw new Error(`Tab "${tabName}" in ${selector.join('.')} is disabled and cannot be ${openOrClose ? 'opened' : 'closed'}`);
721
+ }
716
722
  const action = openOrClose ? 'Opening' : 'Closing';
717
723
  cy.log(`${action} tab ${tabName}`);
718
724
  cy.wrap($tab).click();
@@ -960,21 +966,43 @@ Cypress.Commands.add('GLO_filterGrid', (colId, selector, text, exactMatch = noEq
960
966
  }
961
967
  });
962
968
  if (filterType) {
969
+ // The typed value needs to sync into the grid's filter model before the operator
970
+ // menu opens, or the menu's own value field still reads empty and the operator
971
+ // picker starts from a stale/default state (reproduced manually: opening the menu
972
+ // too soon after typing shows the filter value field blank).
973
+ cy.GLO_checkLoadingIndicator();
963
974
  cy.get('@filterHeaderCell').find('button').click();
964
- cy.get('.ag-filter-menu .ag-filter-select .ag-picker-field-display').click();
965
- // biome-ignore plugin: special dropdown for filter, doesn't work without small wait
966
- cy.wait(250); // Special dropdown for filter, doesn't work without small wait..
967
- cy.get('.ag-filter-menu .ag-picker-expanded')
968
- .invoke('attr', 'aria-controls')
969
- .then((dropDownId) => {
970
- cy.get(`[id="${dropDownId}"]`)
971
- .should('be.visible')
972
- .within(() => {
973
- cy.get('.ag-list-item').contains(filterType).should('be.visible').parent().click();
975
+ // Selecting the operator in this popup is occasionally flaky in Cypress (the click
976
+ // registers but the picker display doesn't update to the new operator - reproduced
977
+ // manually in the live app and ruled out as a wrong-popup/stale-aria-controls issue;
978
+ // the open+select sequence itself is correct). Self-heal by reopening and reselecting
979
+ // instead of guessing at the exact cause of the dropped click.
980
+ // A compound AND/OR filter always has a second condition row scaffolded in (even
981
+ // when unused), each with its own operator picker - scope to .first() throughout so
982
+ // this always targets the primary condition's picker instead of matching 2 elements.
983
+ const selectFilterType = (attemptsLeft) => {
984
+ cy.get('.ag-filter-menu .ag-filter-select .ag-picker-field-display').first().click();
985
+ cy.wait(250); // Special dropdown for filter, doesn't work without small wait..
986
+ cy.get('.ag-filter-menu .ag-picker-expanded')
987
+ .invoke('attr', 'aria-controls')
988
+ .then((dropDownId) => {
989
+ cy.get(`[id="${dropDownId}"]`)
990
+ .should('be.visible')
991
+ .within(() => {
992
+ cy.get('.ag-list-item').contains(filterType).should('be.visible').parent().click();
993
+ });
974
994
  });
975
- });
976
- cy.GLO_checkAGLoader();
977
- cy.get('.ag-filter-menu .ag-filter-select .ag-picker-field-display').should('contain.text', filterType);
995
+ cy.GLO_checkAGLoader();
996
+ cy.get('.ag-filter-menu .ag-filter-select .ag-picker-field-display')
997
+ .first()
998
+ .then(($el) => {
999
+ if ($el.text().trim() !== filterType && attemptsLeft > 0) {
1000
+ selectFilterType(attemptsLeft - 1);
1001
+ }
1002
+ });
1003
+ };
1004
+ selectFilterType(2);
1005
+ cy.get('.ag-filter-menu .ag-filter-select .ag-picker-field-display').first().should('contain.text', filterType);
978
1006
  cy.press(Cypress.Keyboard.Keys.ESC); // Close the filter menu
979
1007
  }
980
1008
  cy.GLO_checkLoadingIndicator();
@@ -1141,7 +1169,7 @@ const dblClickRowInGrid = (type, value, columnId, equals) => {
1141
1169
  };
1142
1170
  const helper = (filterSelector) => {
1143
1171
  if (columnId) {
1144
- cy.get('[row-index]')
1172
+ cy.get('[row-index]:visible')
1145
1173
  .find(`[col-id="${columnId}"]`)
1146
1174
  .filter(filterSelector, { timeout: 10000 })
1147
1175
  .parentsUntil('.ag-center-cols-container')
@@ -1150,7 +1178,7 @@ const dblClickRowInGrid = (type, value, columnId, equals) => {
1150
1178
  .then(saveValues);
1151
1179
  }
1152
1180
  else {
1153
- cy.get('[row-index]').filter(filterSelector, { timeout: 10000 }).first().then(saveValues);
1181
+ cy.get('[row-index]:visible').filter(filterSelector, { timeout: 10000 }).first().then(saveValues);
1154
1182
  }
1155
1183
  };
1156
1184
  cy.GLO_checkAGLoader();
@@ -1807,6 +1835,9 @@ Cypress.Commands.add('GLO_fillInfieldInGrid', (selector, rowFilter, columnId, va
1807
1835
  cy.GLO_getElement(selector).within(() => {
1808
1836
  cy.GLO_clickRowInGrid(rowFilter, columnId);
1809
1837
  });
1838
+ // Re-check outside the .within(): loading-indicator renders at page level, so the check
1839
+ // inside GLO_clickRowInGrid (scoped to the grid element above) can't see it and resolves early.
1840
+ cy.GLO_checkLoadingIndicator();
1810
1841
  cy.wait(500); // Else both click and dblclick fire too fast
1811
1842
  cy.GLO_dblClickRowInGrid(row, selector, rowFilter, columnId, skipCheck);
1812
1843
  }
@@ -2143,6 +2174,11 @@ Cypress.Commands.add('GLO_checkFieldEnabled', (selector, shouldBeEnabled = enabl
2143
2174
  }
2144
2175
  });
2145
2176
  });
2177
+ Cypress.Commands.add('GLO_checkTabEnabled', (selector, tabName, shouldBeEnabled = enabled) => {
2178
+ cy.GLO_getElement(selector)
2179
+ .contains('[tabcontainer] .tab', tabName)
2180
+ .should(shouldBeEnabled ? 'not.have.attr' : 'have.attr', 'disabled');
2181
+ });
2146
2182
  Cypress.Commands.add('GLO_checkFieldBgColor', (selector, color = 'red') => {
2147
2183
  const [formName, fieldOrFields] = selector;
2148
2184
  const fields = Array.isArray(fieldOrFields) ? fieldOrFields : [fieldOrFields];
@@ -2290,7 +2326,12 @@ Cypress.Commands.add('GLO_extendedFilter', (filter, clearExistingFilters) => {
2290
2326
  cy.wrap($field).find('button.fa-search').click();
2291
2327
  cy.GLO_getElement(['svy_nav_selection', 'search_criteria']).type(String(v));
2292
2328
  cy.GLO_clickButton(['svy_nav_selection', 'btn_search']);
2293
- dblClickRowInGrid(span, String(v));
2329
+ // dblClickRowInGrid is document-scoped unless wrapped in .within() - without this,
2330
+ // its unscoped [row-index] lookup can match a row in whatever grid sits behind this
2331
+ // dialog instead of the dialog's own grid. Same scoping GLO_popupWindow uses.
2332
+ cy.GLO_getVisible('.svy-dialog.active .window-body').within(() => {
2333
+ dblClickRowInGrid(span, String(v));
2334
+ });
2294
2335
  cy.GLO_checkLoadingIndicator();
2295
2336
  }
2296
2337
  else {
@@ -2312,8 +2353,6 @@ Cypress.Commands.add('GLO_extendedFilter', (filter, clearExistingFilters) => {
2312
2353
  // Fall through
2313
2354
  case 'before':
2314
2355
  case 'after':
2315
- case '<= today +/- days':
2316
- case '>= today +/- days':
2317
2356
  case 'contains':
2318
2357
  case 'starts with':
2319
2358
  case 'ends with':
@@ -2323,6 +2362,32 @@ Cypress.Commands.add('GLO_extendedFilter', (filter, clearExistingFilters) => {
2323
2362
  case '<=':
2324
2363
  filterComponent().find(getFilterFieldSelector(path, 'value')).type(`{selectall}{del}${v}{enter}`);
2325
2364
  return;
2365
+ case '<= today +/- days':
2366
+ case '>= today +/- days': {
2367
+ // This operator renders two separate controls (a sign select + a days number box),
2368
+ // not the single 'value' input the other operators share - getFilterFieldSelector's
2369
+ // generic 'value' selector doesn't match either one.
2370
+ const num = Number(v);
2371
+ const signInput = () => filterComponent().find(`[data-cy="filter-builder-xdays-sign-${path}"] .dx-texteditor-input`);
2372
+ signInput().then(($el) => {
2373
+ const id = $el[0].getAttribute('aria-controls');
2374
+ if (!id)
2375
+ cy.wrap($el).click();
2376
+ });
2377
+ signInput().then(($el) => {
2378
+ const id = $el[0].getAttribute('aria-controls');
2379
+ if (!id)
2380
+ throw new Error('Missing aria-controls');
2381
+ cy.get(`[id="${id}"] .dx-scrollview-content`)
2382
+ .children()
2383
+ .contains(num < 0 ? '-' : '+')
2384
+ .click();
2385
+ });
2386
+ filterComponent()
2387
+ .find(`[data-cy="filter-builder-xdays-value-${path}"] .dx-texteditor-input`)
2388
+ .type(`{selectall}{del}${Math.abs(num)}{enter}`);
2389
+ return;
2390
+ }
2326
2391
  default: {
2327
2392
  const _exhaustive = operator;
2328
2393
  throw new Error(`Unhandled operator: ${String(_exhaustive)}`);
@@ -35,6 +35,10 @@ let lastCypressLocator = '';
35
35
  let lastGLOCommand = '';
36
36
  let lastActionStartTime = null;
37
37
  let wsInteractionCounter = 0;
38
+ // Module-scoped (not per-WS_loadingCheck-invocation): a componentApi call's reply can arrive after
39
+ // the invocation that recorded its smsgid has already resolved, so tracking has to survive across
40
+ // invocations within a test — cleared per-test in beforeEach below, same as the other cross-invocation state.
41
+ const componentApiSmsgids = new Set();
38
42
  Cypress.on('command:start', (cmd) => {
39
43
  const rawName = cmd.get('name');
40
44
  const name = typeof rawName === 'string' ? rawName : '';
@@ -57,6 +61,7 @@ beforeEach(() => {
57
61
  lastGLOCommand = '';
58
62
  lastActionStartTime = null;
59
63
  wsInteractionCounter = 0;
64
+ componentApiSmsgids.clear();
60
65
  });
61
66
  after(() => {
62
67
  cy.task('flushWsMetrics', null, { log: false }).then((result) => {
@@ -828,17 +833,31 @@ Cypress.Commands.add('WS_loadingCheck', (options) => {
828
833
  debugLog('🔁 Ignored columnState-only dataPush');
829
834
  return;
830
835
  }
836
+ // Server-initiated componentApi calls (e.g. internalGetColumnState) carry their own smsgid the
837
+ // client will echo back in its reply - record it so checkForDialogInteraction can recognize that
838
+ // reply as unrelated to a dialog, rather than mistaking it for one (both share the {smsgid, ret}
839
+ // wire shape).
840
+ const componentApisField = data.componentApis;
841
+ if (Array.isArray(componentApisField) && 'smsgid' in data && typeof data.smsgid === 'number') {
842
+ componentApiSmsgids.add(data.smsgid);
843
+ }
831
844
  const hasDialog = 'serviceApis' in data && Array.isArray(data.serviceApis) && checkForDialogInResponse(data.serviceApis);
845
+ // Response triggers a dialog — hold the triggering request until the dialog is dismissed.
846
+ // The held id is threaded through to updateDialogState so the dialog record is attributed to
847
+ // the exact request that was held, instead of updateDialogState independently re-deriving it
848
+ // from state.lastOutgoingRequestId (which can point at a different, possibly already-resolved
849
+ // request when several requests are in flight - leaking the hold forever, since dismissal only
850
+ // releases a held request whose id matches the dialog's recorded triggeringRequestId exactly).
851
+ let heldRequestId = null;
832
852
  if (hasDialog) {
833
- // Response triggers a dialog — hold the triggering request until the dialog is dismissed
834
- handleDialogResponse(data);
853
+ heldRequestId = handleDialogResponse(data);
835
854
  }
836
855
  else {
837
856
  handleNormalResponse(data);
838
857
  }
839
858
  if ('serviceApis' in data && Array.isArray(data.serviceApis)) {
840
859
  updateLoadingState(data.serviceApis);
841
- updateDialogState(data.serviceApis);
860
+ updateDialogState(data.serviceApis, heldRequestId);
842
861
  updateExpectFormShowState(data.serviceApis);
843
862
  }
844
863
  // Check if this response echoes back a pending svyPush data change
@@ -889,6 +908,10 @@ Cypress.Commands.add('WS_loadingCheck', (options) => {
889
908
  function checkForDialogInteraction(data) {
890
909
  if (!('smsgid' in data && 'ret' in data && typeof data.ret === 'string'))
891
910
  return;
911
+ // This reply answers a componentApi call (e.g. internalGetColumnState), not a dialog button -
912
+ // it just happens to share the same {smsgid, ret} wire shape. Ignore it here.
913
+ if (typeof data.smsgid === 'number' && componentApiSmsgids.delete(data.smsgid))
914
+ return;
892
915
  state.extendedQuietPeriod = true;
893
916
  // Release the oldest held non-Window pending dialog (FIFO order)
894
917
  for (const [key, dialogData] of pendingDialogs) {
@@ -955,6 +978,8 @@ Cypress.Commands.add('WS_loadingCheck', (options) => {
955
978
  /**
956
979
  * When a response arrives that also opens a dialog, the triggering request is held
957
980
  * rather than resolved. The actual result will arrive after the dialog is dismissed.
981
+ * Returns the held request id so the caller can attribute the dialog record to this exact
982
+ * request (see the comment in handleIncomingMessage for why that matters).
958
983
  */
959
984
  function handleDialogResponse(data) {
960
985
  let targetId = null;
@@ -971,6 +996,7 @@ Cypress.Commands.add('WS_loadingCheck', (options) => {
971
996
  state.heldRequests.add(targetId);
972
997
  debugLog(`🔲 Dialog detected - holding request: ${targetId}`);
973
998
  }
999
+ return targetId;
974
1000
  }
975
1001
  function wsRequestPhase(sendTs) {
976
1002
  // No loading detected yet at response time → pre_loading
@@ -1180,10 +1206,10 @@ Cypress.Commands.add('WS_loadingCheck', (options) => {
1180
1206
  });
1181
1207
  }
1182
1208
  /** Dispatches serviceApi calls to the appropriate dialog/window handler. */
1183
- function updateDialogState(apis) {
1209
+ function updateDialogState(apis, heldRequestId = null) {
1184
1210
  apis.forEach((api) => {
1185
1211
  if (api.name === 'dialogs')
1186
- handleMessageDialog(api);
1212
+ handleMessageDialog(api, heldRequestId);
1187
1213
  if (api.name === '$windowService')
1188
1214
  handleWindowDialog(api);
1189
1215
  if (api.name === 'window' && api.call === 'cancelFormPopupInternal')
@@ -1212,8 +1238,14 @@ Cypress.Commands.add('WS_loadingCheck', (options) => {
1212
1238
  * Only Info dialogs with the literal title "Info" are auto-handled and block the loader.
1213
1239
  * All others (Warning, Error, Input, Select, Info with a different title) use a "Question:"
1214
1240
  * key which unblocks the loader so the test can interact with the dialog.
1241
+ *
1242
+ * triggeringRequestId must be the exact request id handleDialogResponse held for this same
1243
+ * incoming message (passed in as heldRequestId), not independently re-derived from
1244
+ * state.lastOutgoingRequestId - that can point at a different (or already-resolved) request
1245
+ * when several requests are in flight, which would leave the actually-held request unreleasable
1246
+ * once the dialog is dismissed and permanently hang the loader.
1215
1247
  */
1216
- function handleMessageDialog(api) {
1248
+ function handleMessageDialog(api, heldRequestId = null) {
1217
1249
  if (!api.call || !DIALOG_METHODS.includes(api.call))
1218
1250
  return;
1219
1251
  const dialogType = api.call.replace('show', '').replace('Dialog', '');
@@ -1221,7 +1253,7 @@ Cypress.Commands.add('WS_loadingCheck', (options) => {
1221
1253
  // 'Info' key → loader blocks until auto-handled; 'Question' key → loader unblocks immediately
1222
1254
  const keyType = dialogType === 'Info' && wsTitle === 'Info' ? 'Info' : 'Question';
1223
1255
  const dialogKey = `${keyType}:${++dialogIdCounter}`;
1224
- pendingDialogs.set(dialogKey, { dialogType, triggeringRequestId: state.lastOutgoingRequestId, timestamp: Date.now() });
1256
+ pendingDialogs.set(dialogKey, { dialogType, triggeringRequestId: heldRequestId, timestamp: Date.now() });
1225
1257
  debugLog(`🔲 ${dialogType} dialog opened: ${api.call}`, { ...createDialogDebugInfo(api), dialogKey, wsTitle });
1226
1258
  }
1227
1259
  /** Routes $windowService API calls to create/show/destroy handlers. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@globisnv/cypress-core",
3
- "version": "1.1.0",
3
+ "version": "1.1.2",
4
4
  "description": "Core building blocks for Globis Cypress E2E tests",
5
5
  "license": "UNLICENSED",
6
6
  "type": "module",
@@ -12,7 +12,8 @@
12
12
  "url": "git+https://github.com/globisnv/globis-cypress-core.git"
13
13
  },
14
14
  "publishConfig": {
15
- "access": "public"
15
+ "access": "public",
16
+ "tag": "release-1.x"
16
17
  },
17
18
  "globisOnline": {
18
19
  "minVersion": "11.0.2"
@@ -59,7 +60,7 @@
59
60
  "devDependencies": {
60
61
  "@biomejs/biome": "^2.5.7",
61
62
  "@types/node": "^26.1.2",
62
- "cypress": "^16.1.0",
63
+ "cypress": "^16.1.1",
63
64
  "cypress-real-events": "^1.15.1",
64
65
  "typescript": "^7.0.2"
65
66
  },