@globisnv/cypress-core 1.1.0 → 2.0.0

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/README.md CHANGED
@@ -34,6 +34,7 @@ See [Cutting a release](#cutting-a-release) for how a bump actually gets publish
34
34
 
35
35
  | Core version | Minimum globis-online version |
36
36
  | --- | --- |
37
+ | `2.0.0` | `11.0.3` |
37
38
  | `0.0.1` | `11.0.2` |
38
39
 
39
40
  The floor is recorded in `package.json` under `globisOnline.minVersion` (machine-readable, in case tooling wants to check it later) and here in this table (human-readable, updated on every release alongside `CHANGELOG.md`). `globis-cypress` stays pinned to the latest core version — since it's Globis's own suite, it's the first thing to catch a globis-online release breaking something, before any customer repo would.
@@ -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
  *
@@ -33,6 +33,9 @@ const isGridRowSelector = (baseSelector, index) => {
33
33
  // rendered-but-scrolled-past rows as hidden (measured: 14 of 21 rendered rows drop out of `:visible`
34
34
  // after scrolling a grid to the bottom). Filter on the container, which is what both were after.
35
35
  const isInOpenContainer = (_, el) => !el.closest('[hidden]');
36
+ // Data rows of an AG Grid 36 body. Group rows (full-width) share the scrolling container with the data rows,
37
+ // so they are excluded to keep row indexes and counts about data rows only.
38
+ const gridDataRows = '.ag-grid-scrolling-container [row-index]:not(.ag-full-width-row)';
36
39
  const pickByRowIndex = (chain, index, log = false, timeout = defaultTimeout) => {
37
40
  if (log)
38
41
  Cypress.log({ name: 'idx', message: String(index) });
@@ -713,6 +716,12 @@ Cypress.Commands.add('GLO_selectTab', (selector, tabName, openOrClose) => {
713
716
  const isOpenAttr = $tab[0].getAttribute('open'); // Attribute is a string 'true' or missing -> null
714
717
  const isOpen = isOpenAttr === 'true'; // Convert to boolean
715
718
  if (openOrClose !== isOpen) {
719
+ // The tab header is a div, so Cypress's `:disabled` actionability check doesn't apply: the click goes through
720
+ // and the server silently ignores it. Closing a disabled tab that is open fails too, because the close request
721
+ // goes through the same click on the same disabled tab, which the server ignores the same way.
722
+ if ($tab[0].hasAttribute('disabled')) {
723
+ throw new Error(`Tab "${tabName}" in ${selector.join('.')} is disabled and cannot be ${openOrClose ? 'opened' : 'closed'}`);
724
+ }
716
725
  const action = openOrClose ? 'Opening' : 'Closing';
717
726
  cy.log(`${action} tab ${tabName}`);
718
727
  cy.wrap($tab).click();
@@ -750,7 +759,7 @@ const navItem = (topBottom, programName, tabNumber, mode) => {
750
759
  throw new Error(`Incorrect type for GLO_${mode}Program. Available types: top, bottom`);
751
760
  const location = topBottom === top ? 0 : 1;
752
761
  const regex = new RegExp(`^${Cypress._.escapeRegExp(programName)}$`);
753
- cy.get('drag-scroll')
762
+ cy.get('[navigation] > [nodelist]')
754
763
  .eq(location)
755
764
  .within(() => {
756
765
  const items = cy
@@ -1066,13 +1075,13 @@ Cypress.Commands.add('GLO_selectProgram', (topBottom, programName, tabnumber) =>
1066
1075
  navItem(topBottom, programName, tabnumber, 'select');
1067
1076
  });
1068
1077
  Cypress.Commands.add('GLO_getRowInGrid', (rowFilter) => {
1069
- const rows = cy.get('.ag-center-cols-container [row-index]').filter(isInOpenContainer);
1078
+ const rows = cy.get(gridDataRows).filter(isInOpenContainer);
1070
1079
  return typeof rowFilter === 'number' ? rows.eq(rowFilter) : rows.filter(`:contains("${rowFilter}")`).first();
1071
1080
  });
1072
1081
  Cypress.Commands.add('GLO_selectRowInGrid', (rowFilter) => {
1073
1082
  let found = false;
1074
1083
  if (typeof rowFilter === 'number') {
1075
- cy.get(`[row-id="root_${rowFilter}"]`).filter(isInOpenContainer).find('[data-ref="eInput"]').click();
1084
+ cy.get(`[row-id="root_${rowFilter}"]`).filter(isInOpenContainer).find('.ag-grid-pinned-left-cells [data-ref="eInput"]').click();
1076
1085
  return;
1077
1086
  }
1078
1087
  // Convert to array if it's a string
@@ -1086,15 +1095,8 @@ Cypress.Commands.add('GLO_selectRowInGrid', (rowFilter) => {
1086
1095
  const matches = filters.every((filterValue) => cellTexts.some((val) => val.includes(filterValue)));
1087
1096
  if (matches) {
1088
1097
  found = true;
1089
- const rowId = $row.attr('row-id');
1090
- // No isInOpenContainer here: `.ag-root` is the grid of $row, which already passed that
1091
- // filter above, so everything inside shares its ancestors. The re-query is only needed
1092
- // because the checkbox lives in the pinned-left twin carrying the same row-id.
1093
- cy.wrap($row)
1094
- .closest('.ag-root')
1095
- .within(() => {
1096
- cy.get(`[row-id="${rowId}"]`).find('[data-ref="eInput"]').click();
1097
- });
1098
+ // AG Grid 36 renders the pinned-left selection checkbox inside the row itself.
1099
+ cy.wrap($row).find('.ag-grid-pinned-left-cells [data-ref="eInput"]').click();
1098
1100
  return false; // Stop after the first match
1099
1101
  }
1100
1102
  })
@@ -1141,16 +1143,16 @@ const dblClickRowInGrid = (type, value, columnId, equals) => {
1141
1143
  };
1142
1144
  const helper = (filterSelector) => {
1143
1145
  if (columnId) {
1144
- cy.get('[row-index]')
1146
+ cy.get('[row-index]:visible')
1145
1147
  .find(`[col-id="${columnId}"]`)
1146
1148
  .filter(filterSelector, { timeout: 10000 })
1147
- .parentsUntil('.ag-center-cols-container')
1149
+ .parentsUntil('.ag-grid-scrolling-container')
1148
1150
  .filter('[row-index]')
1149
1151
  .first()
1150
1152
  .then(saveValues);
1151
1153
  }
1152
1154
  else {
1153
- cy.get('[row-index]').filter(filterSelector, { timeout: 10000 }).first().then(saveValues);
1155
+ cy.get('[row-index]:visible').filter(filterSelector, { timeout: 10000 }).first().then(saveValues);
1154
1156
  }
1155
1157
  };
1156
1158
  cy.GLO_checkAGLoader();
@@ -1167,7 +1169,7 @@ const dblClickRowInGrid = (type, value, columnId, equals) => {
1167
1169
  cy.get('[row-index]:visible')
1168
1170
  .find(columnId ? `[col-id="${columnId}"] ${span}` : span)
1169
1171
  .contains(regex, { timeout: 10000 })
1170
- .parentsUntil('.ag-center-cols-container')
1172
+ .parentsUntil('.ag-grid-scrolling-container')
1171
1173
  .filter('[row-index]')
1172
1174
  .first()
1173
1175
  .then(saveValues);
@@ -1192,13 +1194,13 @@ const dblClickRowInGrid = (type, value, columnId, equals) => {
1192
1194
  }
1193
1195
  // The actual dblClick on the row
1194
1196
  cy.get('@COM_selectedRowSelector').then((selector) => {
1195
- columnId ? cy.get(selector).find(`[col-id="${columnId}"]`).dblclick() : cy.get(selector).find('[aria-colindex]').eq(1).dblclick();
1197
+ columnId ? cy.get(selector).find(`[col-id="${columnId}"]`).dblclick() : cy.get(selector).find('.ag-grid-scrolling-cells [aria-colindex]').eq(1).dblclick();
1196
1198
  });
1197
1199
  };
1198
1200
  Cypress.Commands.add('GLO_clickRowInGrid', (value, columnId) => {
1199
1201
  if (value === undefined)
1200
1202
  throw new Error('Value missing for GLO_clickRowInGrid');
1201
- columnId ? cy.GLO_getRowInGrid(value).find(`[col-id="${columnId}"]`).click() : cy.GLO_getRowInGrid(value).children().first().click();
1203
+ columnId ? cy.GLO_getRowInGrid(value).find(`[col-id="${columnId}"]`).click() : cy.GLO_getRowInGrid(value).find('.ag-grid-scrolling-cells').children().first().click();
1202
1204
  cy.GLO_checkLoadingIndicator();
1203
1205
  });
1204
1206
  // Shared popup-window search + grid selection logic used by GLO_popupWindow.
@@ -1211,7 +1213,9 @@ const selectFromPopupWindow = (value, columnId, equals = noEqualBool) => {
1211
1213
  const loadingColumnId = () => columnId
1212
1214
  ? cy.wrap(columnId)
1213
1215
  : cy
1214
- .get('.svy-dialog.active .window-body .ag-center-cols-container [row-index="0"] [role="gridcell"]', { timeout: 30000 })
1216
+ .get('.svy-dialog.active .window-body .ag-grid-scrolling-container [row-index="0"]:not(.ag-full-width-row) .ag-grid-scrolling-cells [role="gridcell"]', {
1217
+ timeout: 30000
1218
+ })
1215
1219
  .filter((_, el) => Cypress.$(el).text().trim().length > 0)
1216
1220
  .first()
1217
1221
  .invoke('attr', 'col-id');
@@ -1475,13 +1479,15 @@ Cypress.Commands.add('GLO_checkNumberOfRowsInGrid', (selector, numberToValidate,
1475
1479
  const pollInterval = 5000;
1476
1480
  const HEADER_ROWS = 2;
1477
1481
  const getAriaRowcount = ($grid) => parseInt($grid.find('[role="grid"]').attr('aria-rowcount') || '0', 10);
1478
- // Column header row + filter row, counted in the center viewport only (the pinned-left and
1479
- // pinned-right containers repeat those same header rows).
1480
- const headerRowCount = ($grid) => $grid.find('.ag-header-viewport .ag-header-row').length || HEADER_ROWS;
1482
+ // Column header row + filter row (AG Grid 36 renders each header row once, pinned cells included).
1483
+ const headerRowCount = ($grid) => $grid.find('.ag-header .ag-header-row').length || HEADER_ROWS;
1484
+ // Rows aria-rowcount counts that are not data rows: header rows plus pinned top/bottom rows (e.g. a totals
1485
+ // row), which AG Grid 36 includes in aria-rowcount. Pinned rows are never virtualised, so the DOM count is exact.
1486
+ const nonDataRowCount = ($grid) => headerRowCount($grid) + $grid.find('.ag-row-pinned').length;
1481
1487
  const isLazyLoaded = ($grid) => {
1482
1488
  const ariaRowcount = getAriaRowcount($grid);
1483
- const renderedRows = $grid.find('.ag-center-cols-container [row-index]').length;
1484
- return renderedRows > 0 && (ariaRowcount === -1 || ariaRowcount > renderedRows + headerRowCount($grid));
1489
+ const renderedRows = $grid.find(gridDataRows).length;
1490
+ return renderedRows > 0 && (ariaRowcount === -1 || ariaRowcount > renderedRows + nonDataRowCount($grid));
1485
1491
  };
1486
1492
  // When a client-side column filter is active, the tab-/datacontrol-counter still shows the
1487
1493
  // unfiltered foundset total, so we must count the rendered (filtered) rows instead.
@@ -1503,12 +1509,12 @@ Cypress.Commands.add('GLO_checkNumberOfRowsInGrid', (selector, numberToValidate,
1503
1509
  const total = parseInt(parts[1].trim(), 10);
1504
1510
  return Number.isNaN(total) ? null : total;
1505
1511
  };
1506
- // AG Grid publishes the displayed row total (header rows included, pinned "sum" rows
1507
- // excluded) in aria-rowcount, so a virtualised grid still reports rows the viewport never
1508
- // rendered. Verified on ng$consignment_dtl_general.grid: 25 goods -> aria-rowcount 27 with
1509
- // 2 header rows, while only 12 rows exist in the DOM.
1512
+ // AG Grid publishes the displayed row total (header and pinned rows included) in aria-rowcount,
1513
+ // so a virtualised grid still reports rows the viewport never rendered. Verified on
1514
+ // ng$consignment_dtl_general.grid: 25 goods -> aria-rowcount 27 with 2 header rows, while only
1515
+ // 12 rows exist in the DOM.
1510
1516
  const getCountFromAriaRowcount = ($grid) => {
1511
- const total = getAriaRowcount($grid) - headerRowCount($grid);
1517
+ const total = getAriaRowcount($grid) - nonDataRowCount($grid);
1512
1518
  return total >= 0 ? total : null; // An aria-rowcount of -1 means the total is unknown (infinite row model)
1513
1519
  };
1514
1520
  // Form-level counters (#txtCount navigator, tab "(n)" label) only match a grid on list
@@ -1552,7 +1558,7 @@ Cypress.Commands.add('GLO_checkNumberOfRowsInGrid', (selector, numberToValidate,
1552
1558
  runChainAssertion(tabCount);
1553
1559
  }
1554
1560
  else {
1555
- runChainAssertion($grid.find('.ag-center-cols-container [row-index]'));
1561
+ runChainAssertion($grid.find(gridDataRows));
1556
1562
  }
1557
1563
  });
1558
1564
  }
@@ -1563,7 +1569,7 @@ Cypress.Commands.add('GLO_checkNumberOfRowsInGrid', (selector, numberToValidate,
1563
1569
  const log = Cypress.log({ name: 'checkNumberOfRowsInGrid', message: `Polling rows (${lengthCondition} ${numberToValidate})…`, autoEnd: false, timeout: 0 });
1564
1570
  const checkStatus = (resolve, reject) => {
1565
1571
  cy.GLO_getElement(selector, { timeout: timeOut, log: false }).then(($grid) => {
1566
- const $rows = $grid.find('.ag-center-cols-container [row-index]');
1572
+ const $rows = $grid.find(gridDataRows);
1567
1573
  let actual = $rows.length;
1568
1574
  let passed = false;
1569
1575
  try {
@@ -1762,7 +1768,7 @@ Cypress.Commands.add('GLO_selectRows', (selector, rowNumber) => {
1762
1768
  }
1763
1769
  };
1764
1770
  cy.GLO_getElement(selector)
1765
- .find('.ag-body-viewport')
1771
+ .find('.ag-grid-scrolling-rows')
1766
1772
  .within(() => {
1767
1773
  cy.GLO_clickRowInGrid(0); // Always click the first row
1768
1774
  if (rowNumber !== undefined) {
@@ -1777,7 +1783,7 @@ Cypress.Commands.add('GLO_selectRows', (selector, rowNumber) => {
1777
1783
  });
1778
1784
  Cypress.Commands.add('GLO_sortGrid', (selector, headerText) => {
1779
1785
  cy.GLO_getElement(selector)
1780
- .find('.ag-header-viewport .ag-header-row.ag-header-row-column')
1786
+ .find('.ag-header .ag-header-row.ag-header-row-column')
1781
1787
  .within(() => {
1782
1788
  cy.get('.ag-header-cell').contains(headerText).click();
1783
1789
  });
@@ -1960,14 +1966,15 @@ Cypress.Commands.add('GLO_getAllRowsInGrid', (selector, timeOut = defaultTimeout
1960
1966
  const deadline = Date.now() + timeOut;
1961
1967
  return cy
1962
1968
  .GLO_getElement(selector, { timeout: timeOut })
1963
- .find('.ag-center-cols-container [row-index]', { timeout: timeOut + 500 })
1969
+ .find(gridDataRows, { timeout: timeOut + 500 })
1964
1970
  .should(($rows) => {
1965
1971
  if ($rows.length === 0 && Date.now() < deadline)
1966
1972
  throw new Error('GLO_getAllRowsInGrid: waiting for rows');
1967
1973
  });
1968
1974
  }
1969
1975
  else {
1970
- return cy.get('.ag-center-cols-container [row-index] [role="gridcell"]', { timeout: timeOut }).parent().filter(isInOpenContainer);
1976
+ // AG Grid 36 nests cells in pinned/scrolling wrappers, so a cell's parent is no longer the row.
1977
+ return cy.get(`${gridDataRows}:has([role="gridcell"])`, { timeout: timeOut }).filter(isInOpenContainer);
1971
1978
  }
1972
1979
  });
1973
1980
  Cypress.Commands.add('GLO_validateGridOrFail', (waitValue, failValue, rowNumber, columnId, timeOut = 600000, interval = 15000) => {
@@ -2143,6 +2150,11 @@ Cypress.Commands.add('GLO_checkFieldEnabled', (selector, shouldBeEnabled = enabl
2143
2150
  }
2144
2151
  });
2145
2152
  });
2153
+ Cypress.Commands.add('GLO_checkTabEnabled', (selector, tabName, shouldBeEnabled = enabled) => {
2154
+ cy.GLO_getElement(selector)
2155
+ .contains('[tabcontainer] .tab', tabName)
2156
+ .should(shouldBeEnabled ? 'not.have.attr' : 'have.attr', 'disabled');
2157
+ });
2146
2158
  Cypress.Commands.add('GLO_checkFieldBgColor', (selector, color = 'red') => {
2147
2159
  const [formName, fieldOrFields] = selector;
2148
2160
  const fields = Array.isArray(fieldOrFields) ? fieldOrFields : [fieldOrFields];
@@ -2290,7 +2302,12 @@ Cypress.Commands.add('GLO_extendedFilter', (filter, clearExistingFilters) => {
2290
2302
  cy.wrap($field).find('button.fa-search').click();
2291
2303
  cy.GLO_getElement(['svy_nav_selection', 'search_criteria']).type(String(v));
2292
2304
  cy.GLO_clickButton(['svy_nav_selection', 'btn_search']);
2293
- dblClickRowInGrid(span, String(v));
2305
+ // dblClickRowInGrid is document-scoped unless wrapped in .within() - without this,
2306
+ // its unscoped [row-index] lookup can match a row in whatever grid sits behind this
2307
+ // dialog instead of the dialog's own grid. Same scoping GLO_popupWindow uses.
2308
+ cy.GLO_getVisible('.svy-dialog.active .window-body').within(() => {
2309
+ dblClickRowInGrid(span, String(v));
2310
+ });
2294
2311
  cy.GLO_checkLoadingIndicator();
2295
2312
  }
2296
2313
  else {
@@ -2312,8 +2329,6 @@ Cypress.Commands.add('GLO_extendedFilter', (filter, clearExistingFilters) => {
2312
2329
  // Fall through
2313
2330
  case 'before':
2314
2331
  case 'after':
2315
- case '<= today +/- days':
2316
- case '>= today +/- days':
2317
2332
  case 'contains':
2318
2333
  case 'starts with':
2319
2334
  case 'ends with':
@@ -2323,6 +2338,32 @@ Cypress.Commands.add('GLO_extendedFilter', (filter, clearExistingFilters) => {
2323
2338
  case '<=':
2324
2339
  filterComponent().find(getFilterFieldSelector(path, 'value')).type(`{selectall}{del}${v}{enter}`);
2325
2340
  return;
2341
+ case '<= today +/- days':
2342
+ case '>= today +/- days': {
2343
+ // This operator renders two separate controls (a sign select + a days number box),
2344
+ // not the single 'value' input the other operators share - getFilterFieldSelector's
2345
+ // generic 'value' selector doesn't match either one.
2346
+ const num = Number(v);
2347
+ const signInput = () => filterComponent().find(`[data-cy="filter-builder-xdays-sign-${path}"] .dx-texteditor-input`);
2348
+ signInput().then(($el) => {
2349
+ const id = $el[0].getAttribute('aria-controls');
2350
+ if (!id)
2351
+ cy.wrap($el).click();
2352
+ });
2353
+ signInput().then(($el) => {
2354
+ const id = $el[0].getAttribute('aria-controls');
2355
+ if (!id)
2356
+ throw new Error('Missing aria-controls');
2357
+ cy.get(`[id="${id}"] .dx-scrollview-content`)
2358
+ .children()
2359
+ .contains(num < 0 ? '-' : '+')
2360
+ .click();
2361
+ });
2362
+ filterComponent()
2363
+ .find(`[data-cy="filter-builder-xdays-value-${path}"] .dx-texteditor-input`)
2364
+ .type(`{selectall}{del}${Math.abs(num)}{enter}`);
2365
+ return;
2366
+ }
2326
2367
  default: {
2327
2368
  const _exhaustive = operator;
2328
2369
  throw new Error(`Unhandled operator: ${String(_exhaustive)}`);
@@ -19,15 +19,15 @@ Cypress.Commands.add('GLS_mainNavigation', (value) => {
19
19
  throw new Error(`Could not find menu item: ${value}`);
20
20
  }
21
21
  return cy
22
- .get('.ag-body-viewport')
22
+ .get('.ag-grid-scrolling-rows')
23
23
  .first()
24
24
  .then(($viewport) => {
25
25
  const $label = $viewport.find('label').filter((_i, el) => el.innerText.trim() === value);
26
26
  if ($label.length > 0 && $label.is(':visible')) {
27
- return cy.contains('.ag-body-viewport label', value).click();
27
+ return cy.contains('.ag-grid-scrolling-rows label', value).click();
28
28
  }
29
29
  return cy
30
- .get('.ag-center-cols-container [role="row"][tabindex="0"]')
30
+ .get('.ag-grid-scrolling-container [role="row"][tabindex="0"]')
31
31
  .first()
32
32
  .focus()
33
33
  .realPress('ArrowDown')
@@ -65,7 +65,7 @@ Cypress.Commands.add('POR_clickTopDropdownMenu', (linkText) => {
65
65
  });
66
66
  Cypress.Commands.add('POR_checkTableHasData', () => {
67
67
  cy.GLO_getElement(['ep$consignment_lst', 'shipments_grid']).within(() => {
68
- cy.get('.ag-center-cols-container').POR_isNotEmpty();
68
+ cy.get('.ag-grid-scrolling-container').POR_isNotEmpty();
69
69
  });
70
70
  });
71
71
  Cypress.Commands.add('POR_isNotEmpty', { prevSubject: true }, (subject, className) => {
@@ -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": "2.0.0",
4
4
  "description": "Core building blocks for Globis Cypress E2E tests",
5
5
  "license": "UNLICENSED",
6
6
  "type": "module",
@@ -15,7 +15,7 @@
15
15
  "access": "public"
16
16
  },
17
17
  "globisOnline": {
18
- "minVersion": "11.0.2"
18
+ "minVersion": "11.0.3"
19
19
  },
20
20
  "exports": {
21
21
  ".": "./dist/index.js",