@globisnv/cypress-core 1.1.0 → 1.1.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.
@@ -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();
@@ -1141,7 +1147,7 @@ const dblClickRowInGrid = (type, value, columnId, equals) => {
1141
1147
  };
1142
1148
  const helper = (filterSelector) => {
1143
1149
  if (columnId) {
1144
- cy.get('[row-index]')
1150
+ cy.get('[row-index]:visible')
1145
1151
  .find(`[col-id="${columnId}"]`)
1146
1152
  .filter(filterSelector, { timeout: 10000 })
1147
1153
  .parentsUntil('.ag-center-cols-container')
@@ -1150,7 +1156,7 @@ const dblClickRowInGrid = (type, value, columnId, equals) => {
1150
1156
  .then(saveValues);
1151
1157
  }
1152
1158
  else {
1153
- cy.get('[row-index]').filter(filterSelector, { timeout: 10000 }).first().then(saveValues);
1159
+ cy.get('[row-index]:visible').filter(filterSelector, { timeout: 10000 }).first().then(saveValues);
1154
1160
  }
1155
1161
  };
1156
1162
  cy.GLO_checkAGLoader();
@@ -2143,6 +2149,11 @@ Cypress.Commands.add('GLO_checkFieldEnabled', (selector, shouldBeEnabled = enabl
2143
2149
  }
2144
2150
  });
2145
2151
  });
2152
+ Cypress.Commands.add('GLO_checkTabEnabled', (selector, tabName, shouldBeEnabled = enabled) => {
2153
+ cy.GLO_getElement(selector)
2154
+ .contains('[tabcontainer] .tab', tabName)
2155
+ .should(shouldBeEnabled ? 'not.have.attr' : 'have.attr', 'disabled');
2156
+ });
2146
2157
  Cypress.Commands.add('GLO_checkFieldBgColor', (selector, color = 'red') => {
2147
2158
  const [formName, fieldOrFields] = selector;
2148
2159
  const fields = Array.isArray(fieldOrFields) ? fieldOrFields : [fieldOrFields];
@@ -2290,7 +2301,12 @@ Cypress.Commands.add('GLO_extendedFilter', (filter, clearExistingFilters) => {
2290
2301
  cy.wrap($field).find('button.fa-search').click();
2291
2302
  cy.GLO_getElement(['svy_nav_selection', 'search_criteria']).type(String(v));
2292
2303
  cy.GLO_clickButton(['svy_nav_selection', 'btn_search']);
2293
- dblClickRowInGrid(span, String(v));
2304
+ // dblClickRowInGrid is document-scoped unless wrapped in .within() - without this,
2305
+ // its unscoped [row-index] lookup can match a row in whatever grid sits behind this
2306
+ // dialog instead of the dialog's own grid. Same scoping GLO_popupWindow uses.
2307
+ cy.GLO_getVisible('.svy-dialog.active .window-body').within(() => {
2308
+ dblClickRowInGrid(span, String(v));
2309
+ });
2294
2310
  cy.GLO_checkLoadingIndicator();
2295
2311
  }
2296
2312
  else {
@@ -2312,8 +2328,6 @@ Cypress.Commands.add('GLO_extendedFilter', (filter, clearExistingFilters) => {
2312
2328
  // Fall through
2313
2329
  case 'before':
2314
2330
  case 'after':
2315
- case '<= today +/- days':
2316
- case '>= today +/- days':
2317
2331
  case 'contains':
2318
2332
  case 'starts with':
2319
2333
  case 'ends with':
@@ -2323,6 +2337,32 @@ Cypress.Commands.add('GLO_extendedFilter', (filter, clearExistingFilters) => {
2323
2337
  case '<=':
2324
2338
  filterComponent().find(getFilterFieldSelector(path, 'value')).type(`{selectall}{del}${v}{enter}`);
2325
2339
  return;
2340
+ case '<= today +/- days':
2341
+ case '>= today +/- days': {
2342
+ // This operator renders two separate controls (a sign select + a days number box),
2343
+ // not the single 'value' input the other operators share - getFilterFieldSelector's
2344
+ // generic 'value' selector doesn't match either one.
2345
+ const num = Number(v);
2346
+ const signInput = () => filterComponent().find(`[data-cy="filter-builder-xdays-sign-${path}"] .dx-texteditor-input`);
2347
+ signInput().then(($el) => {
2348
+ const id = $el[0].getAttribute('aria-controls');
2349
+ if (!id)
2350
+ cy.wrap($el).click();
2351
+ });
2352
+ signInput().then(($el) => {
2353
+ const id = $el[0].getAttribute('aria-controls');
2354
+ if (!id)
2355
+ throw new Error('Missing aria-controls');
2356
+ cy.get(`[id="${id}"] .dx-scrollview-content`)
2357
+ .children()
2358
+ .contains(num < 0 ? '-' : '+')
2359
+ .click();
2360
+ });
2361
+ filterComponent()
2362
+ .find(`[data-cy="filter-builder-xdays-value-${path}"] .dx-texteditor-input`)
2363
+ .type(`{selectall}{del}${Math.abs(num)}{enter}`);
2364
+ return;
2365
+ }
2326
2366
  default: {
2327
2367
  const _exhaustive = operator;
2328
2368
  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.1",
4
4
  "description": "Core building blocks for Globis Cypress E2E tests",
5
5
  "license": "UNLICENSED",
6
6
  "type": "module",