@globisnv/cypress-core 0.0.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/README.md +255 -0
- package/bin/create-customer-repo.mjs +89 -0
- package/biome.json +49 -0
- package/dist/apiMethods/IGS_request.d.ts +98 -0
- package/dist/apiMethods/IGS_request.js +161 -0
- package/dist/apiMethods/IGS_response.d.ts +47 -0
- package/dist/apiMethods/IGS_response.js +1 -0
- package/dist/blueprints/GLO_addressPopup.d.ts +45 -0
- package/dist/blueprints/GLO_addressPopup.js +96 -0
- package/dist/blueprints/GLO_article.d.ts +45 -0
- package/dist/blueprints/GLO_article.js +110 -0
- package/dist/blueprints/GLO_articleLot.d.ts +12 -0
- package/dist/blueprints/GLO_articleLot.js +23 -0
- package/dist/blueprints/GLO_articleVersion.d.ts +4 -0
- package/dist/blueprints/GLO_articleVersion.js +4 -0
- package/dist/blueprints/GLO_attachmentPopup.d.ts +9 -0
- package/dist/blueprints/GLO_attachmentPopup.js +10 -0
- package/dist/blueprints/GLO_bankAccount.d.ts +7 -0
- package/dist/blueprints/GLO_bankAccount.js +7 -0
- package/dist/blueprints/GLO_billingProcess.d.ts +4 -0
- package/dist/blueprints/GLO_billingProcess.js +4 -0
- package/dist/blueprints/GLO_billingProposal.d.ts +9 -0
- package/dist/blueprints/GLO_billingProposal.js +9 -0
- package/dist/blueprints/GLO_cancel.d.ts +7 -0
- package/dist/blueprints/GLO_cancel.js +7 -0
- package/dist/blueprints/GLO_commandlist.d.ts +10 -0
- package/dist/blueprints/GLO_commandlist.js +10 -0
- package/dist/blueprints/GLO_complaint.d.ts +27 -0
- package/dist/blueprints/GLO_complaint.js +51 -0
- package/dist/blueprints/GLO_composition.d.ts +14 -0
- package/dist/blueprints/GLO_composition.js +20 -0
- package/dist/blueprints/GLO_consignment.d.ts +61 -0
- package/dist/blueprints/GLO_consignment.js +104 -0
- package/dist/blueprints/GLO_consignmentContainerGroup.d.ts +6 -0
- package/dist/blueprints/GLO_consignmentContainerGroup.js +6 -0
- package/dist/blueprints/GLO_consignmentGoods.d.ts +40 -0
- package/dist/blueprints/GLO_consignmentGoods.js +62 -0
- package/dist/blueprints/GLO_consignmentHandling.d.ts +4 -0
- package/dist/blueprints/GLO_consignmentHandling.js +4 -0
- package/dist/blueprints/GLO_consignmentOrder.d.ts +31 -0
- package/dist/blueprints/GLO_consignmentOrder.js +37 -0
- package/dist/blueprints/GLO_consignmentPart.d.ts +13 -0
- package/dist/blueprints/GLO_consignmentPart.js +13 -0
- package/dist/blueprints/GLO_consignmentParts.d.ts +5 -0
- package/dist/blueprints/GLO_consignmentParts.js +12 -0
- package/dist/blueprints/GLO_consignmentPrice.d.ts +11 -0
- package/dist/blueprints/GLO_consignmentPrice.js +19 -0
- package/dist/blueprints/GLO_consignmentSupplier.d.ts +6 -0
- package/dist/blueprints/GLO_consignmentSupplier.js +11 -0
- package/dist/blueprints/GLO_contacts.d.ts +10 -0
- package/dist/blueprints/GLO_contacts.js +14 -0
- package/dist/blueprints/GLO_container.d.ts +6 -0
- package/dist/blueprints/GLO_container.js +4 -0
- package/dist/blueprints/GLO_contract.d.ts +22 -0
- package/dist/blueprints/GLO_contract.js +30 -0
- package/dist/blueprints/GLO_contractArticle.d.ts +17 -0
- package/dist/blueprints/GLO_contractArticle.js +35 -0
- package/dist/blueprints/GLO_contractArticleCost.d.ts +5 -0
- package/dist/blueprints/GLO_contractArticleCost.js +8 -0
- package/dist/blueprints/GLO_countryVatZone.d.ts +8 -0
- package/dist/blueprints/GLO_countryVatZone.js +9 -0
- package/dist/blueprints/GLO_currencyDeviation.d.ts +14 -0
- package/dist/blueprints/GLO_currencyDeviation.js +15 -0
- package/dist/blueprints/GLO_customerArticle.d.ts +12 -0
- package/dist/blueprints/GLO_customerArticle.js +15 -0
- package/dist/blueprints/GLO_customsFlow.d.ts +8 -0
- package/dist/blueprints/GLO_customsFlow.js +19 -0
- package/dist/blueprints/GLO_customsFlowDefinition.d.ts +15 -0
- package/dist/blueprints/GLO_customsFlowDefinition.js +14 -0
- package/dist/blueprints/GLO_customsUnion.d.ts +4 -0
- package/dist/blueprints/GLO_customsUnion.js +4 -0
- package/dist/blueprints/GLO_deliveryReceipt.d.ts +10 -0
- package/dist/blueprints/GLO_deliveryReceipt.js +22 -0
- package/dist/blueprints/GLO_departureSchedule.d.ts +27 -0
- package/dist/blueprints/GLO_departureSchedule.js +57 -0
- package/dist/blueprints/GLO_departureScheduleTimes.d.ts +49 -0
- package/dist/blueprints/GLO_departureScheduleTimes.js +61 -0
- package/dist/blueprints/GLO_document.d.ts +7 -0
- package/dist/blueprints/GLO_document.js +7 -0
- package/dist/blueprints/GLO_documentAttachment.d.ts +4 -0
- package/dist/blueprints/GLO_documentAttachment.js +4 -0
- package/dist/blueprints/GLO_documentDefinitionEntity.d.ts +5 -0
- package/dist/blueprints/GLO_documentDefinitionEntity.js +5 -0
- package/dist/blueprints/GLO_documentGroup.d.ts +4 -0
- package/dist/blueprints/GLO_documentGroup.js +4 -0
- package/dist/blueprints/GLO_documentType.d.ts +12 -0
- package/dist/blueprints/GLO_documentType.js +11 -0
- package/dist/blueprints/GLO_enterCountry.d.ts +21 -0
- package/dist/blueprints/GLO_enterCountry.js +47 -0
- package/dist/blueprints/GLO_enterDiscount.d.ts +8 -0
- package/dist/blueprints/GLO_enterDiscount.js +9 -0
- package/dist/blueprints/GLO_equipment.d.ts +34 -0
- package/dist/blueprints/GLO_equipment.js +55 -0
- package/dist/blueprints/GLO_equipmentCategory.d.ts +15 -0
- package/dist/blueprints/GLO_equipmentCategory.js +14 -0
- package/dist/blueprints/GLO_equipmentEntity.d.ts +5 -0
- package/dist/blueprints/GLO_equipmentEntity.js +5 -0
- package/dist/blueprints/GLO_equipmentServiceCategory.d.ts +13 -0
- package/dist/blueprints/GLO_equipmentServiceCategory.js +13 -0
- package/dist/blueprints/GLO_equipmentType.d.ts +21 -0
- package/dist/blueprints/GLO_equipmentType.js +20 -0
- package/dist/blueprints/GLO_equipmentTypeServiceCategory.d.ts +13 -0
- package/dist/blueprints/GLO_equipmentTypeServiceCategory.js +12 -0
- package/dist/blueprints/GLO_fieldInputValidation.d.ts +46 -0
- package/dist/blueprints/GLO_fieldInputValidation.js +73 -0
- package/dist/blueprints/GLO_formula.d.ts +9 -0
- package/dist/blueprints/GLO_formula.js +9 -0
- package/dist/blueprints/GLO_gang.d.ts +12 -0
- package/dist/blueprints/GLO_gang.js +12 -0
- package/dist/blueprints/GLO_holiday.d.ts +7 -0
- package/dist/blueprints/GLO_holiday.js +8 -0
- package/dist/blueprints/GLO_journey.d.ts +18 -0
- package/dist/blueprints/GLO_journey.js +24 -0
- package/dist/blueprints/GLO_journeyStop.d.ts +32 -0
- package/dist/blueprints/GLO_journeyStop.js +44 -0
- package/dist/blueprints/GLO_layout.d.ts +6 -0
- package/dist/blueprints/GLO_layout.js +6 -0
- package/dist/blueprints/GLO_logging.d.ts +9 -0
- package/dist/blueprints/GLO_logging.js +12 -0
- package/dist/blueprints/GLO_logisticConnection.d.ts +10 -0
- package/dist/blueprints/GLO_logisticConnection.js +11 -0
- package/dist/blueprints/GLO_logisticConnectionType.d.ts +5 -0
- package/dist/blueprints/GLO_logisticConnectionType.js +5 -0
- package/dist/blueprints/GLO_logisticContract.d.ts +28 -0
- package/dist/blueprints/GLO_logisticContract.js +80 -0
- package/dist/blueprints/GLO_logisticContractDetail.d.ts +42 -0
- package/dist/blueprints/GLO_logisticContractDetail.js +122 -0
- package/dist/blueprints/GLO_logisticContractDetailHandling.d.ts +11 -0
- package/dist/blueprints/GLO_logisticContractDetailHandling.js +14 -0
- package/dist/blueprints/GLO_logisticContractDetailItem.d.ts +5 -0
- package/dist/blueprints/GLO_logisticContractDetailItem.js +8 -0
- package/dist/blueprints/GLO_logisticContractItem.d.ts +14 -0
- package/dist/blueprints/GLO_logisticContractItem.js +40 -0
- package/dist/blueprints/GLO_logisticContractItemActivity.d.ts +4 -0
- package/dist/blueprints/GLO_logisticContractItemActivity.js +4 -0
- package/dist/blueprints/GLO_logisticContractItemSurcharge.d.ts +7 -0
- package/dist/blueprints/GLO_logisticContractItemSurcharge.js +7 -0
- package/dist/blueprints/GLO_logisticContractItemSurchargeItem.d.ts +4 -0
- package/dist/blueprints/GLO_logisticContractItemSurchargeItem.js +4 -0
- package/dist/blueprints/GLO_logisticContractReference.d.ts +4 -0
- package/dist/blueprints/GLO_logisticContractReference.js +4 -0
- package/dist/blueprints/GLO_logisticContractVolumeConstraint.d.ts +9 -0
- package/dist/blueprints/GLO_logisticContractVolumeConstraint.js +8 -0
- package/dist/blueprints/GLO_logisticLocation.d.ts +7 -0
- package/dist/blueprints/GLO_logisticLocation.js +7 -0
- package/dist/blueprints/GLO_logisticPriceRequestDetailExternalPrice.d.ts +19 -0
- package/dist/blueprints/GLO_logisticPriceRequestDetailExternalPrice.js +30 -0
- package/dist/blueprints/GLO_logisticPurchaseOrder.d.ts +4 -0
- package/dist/blueprints/GLO_logisticPurchaseOrder.js +4 -0
- package/dist/blueprints/GLO_logistic_waiting_hours.d.ts +6 -0
- package/dist/blueprints/GLO_logistic_waiting_hours.js +6 -0
- package/dist/blueprints/GLO_operationalDefault.d.ts +23 -0
- package/dist/blueprints/GLO_operationalDefault.js +25 -0
- package/dist/blueprints/GLO_outputDetailMail.d.ts +14 -0
- package/dist/blueprints/GLO_outputDetailMail.js +13 -0
- package/dist/blueprints/GLO_postalcode.d.ts +6 -0
- package/dist/blueprints/GLO_postalcode.js +6 -0
- package/dist/blueprints/GLO_priceDependency.d.ts +5 -0
- package/dist/blueprints/GLO_priceDependency.js +4 -0
- package/dist/blueprints/GLO_priceEntry.d.ts +17 -0
- package/dist/blueprints/GLO_priceEntry.js +17 -0
- package/dist/blueprints/GLO_priceRequest.d.ts +8 -0
- package/dist/blueprints/GLO_priceRequest.js +20 -0
- package/dist/blueprints/GLO_priceRequestLine.d.ts +12 -0
- package/dist/blueprints/GLO_priceRequestLine.js +17 -0
- package/dist/blueprints/GLO_principal.d.ts +9 -0
- package/dist/blueprints/GLO_principal.js +13 -0
- package/dist/blueprints/GLO_printPopup.d.ts +10 -0
- package/dist/blueprints/GLO_printPopup.js +62 -0
- package/dist/blueprints/GLO_production.d.ts +9 -0
- package/dist/blueprints/GLO_production.js +9 -0
- package/dist/blueprints/GLO_productionLineMaterial.d.ts +8 -0
- package/dist/blueprints/GLO_productionLineMaterial.js +13 -0
- package/dist/blueprints/GLO_productionMaterial.d.ts +5 -0
- package/dist/blueprints/GLO_productionMaterial.js +5 -0
- package/dist/blueprints/GLO_purchaseCosts.d.ts +6 -0
- package/dist/blueprints/GLO_purchaseCosts.js +9 -0
- package/dist/blueprints/GLO_purchaseInvoice.d.ts +12 -0
- package/dist/blueprints/GLO_purchaseInvoice.js +25 -0
- package/dist/blueprints/GLO_purchaseInvoiceLine.d.ts +8 -0
- package/dist/blueprints/GLO_purchaseInvoiceLine.js +13 -0
- package/dist/blueprints/GLO_purchaseOrder.d.ts +14 -0
- package/dist/blueprints/GLO_purchaseOrder.js +25 -0
- package/dist/blueprints/GLO_purchaseOrderLine.d.ts +10 -0
- package/dist/blueprints/GLO_purchaseOrderLine.js +24 -0
- package/dist/blueprints/GLO_purchasePrices.d.ts +10 -0
- package/dist/blueprints/GLO_purchasePrices.js +10 -0
- package/dist/blueprints/GLO_quotation.d.ts +12 -0
- package/dist/blueprints/GLO_quotation.js +32 -0
- package/dist/blueprints/GLO_quotationLine.d.ts +12 -0
- package/dist/blueprints/GLO_quotationLine.js +28 -0
- package/dist/blueprints/GLO_receipt.d.ts +13 -0
- package/dist/blueprints/GLO_receipt.js +17 -0
- package/dist/blueprints/GLO_receiptFromTempReceipt.d.ts +8 -0
- package/dist/blueprints/GLO_receiptFromTempReceipt.js +8 -0
- package/dist/blueprints/GLO_receiptLine.d.ts +22 -0
- package/dist/blueprints/GLO_receiptLine.js +28 -0
- package/dist/blueprints/GLO_relationAddress.d.ts +26 -0
- package/dist/blueprints/GLO_relationAddress.js +58 -0
- package/dist/blueprints/GLO_relationBankAccount.d.ts +9 -0
- package/dist/blueprints/GLO_relationBankAccount.js +9 -0
- package/dist/blueprints/GLO_relationContact.d.ts +23 -0
- package/dist/blueprints/GLO_relationContact.js +45 -0
- package/dist/blueprints/GLO_relationGroupType.d.ts +7 -0
- package/dist/blueprints/GLO_relationGroupType.js +6 -0
- package/dist/blueprints/GLO_relations.d.ts +67 -0
- package/dist/blueprints/GLO_relations.js +186 -0
- package/dist/blueprints/GLO_relationtype.d.ts +15 -0
- package/dist/blueprints/GLO_relationtype.js +31 -0
- package/dist/blueprints/GLO_remarkType.d.ts +10 -0
- package/dist/blueprints/GLO_remarkType.js +9 -0
- package/dist/blueprints/GLO_remarkTypeDefaultValue.d.ts +4 -0
- package/dist/blueprints/GLO_remarkTypeDefaultValue.js +14 -0
- package/dist/blueprints/GLO_remarkTypeEntityType.d.ts +10 -0
- package/dist/blueprints/GLO_remarkTypeEntityType.js +9 -0
- package/dist/blueprints/GLO_representative.d.ts +7 -0
- package/dist/blueprints/GLO_representative.js +6 -0
- package/dist/blueprints/GLO_salesInvoice.d.ts +9 -0
- package/dist/blueprints/GLO_salesInvoice.js +15 -0
- package/dist/blueprints/GLO_salesInvoiceLine.d.ts +9 -0
- package/dist/blueprints/GLO_salesInvoiceLine.js +13 -0
- package/dist/blueprints/GLO_salesOrder.d.ts +13 -0
- package/dist/blueprints/GLO_salesOrder.js +25 -0
- package/dist/blueprints/GLO_salesOrderLine.d.ts +13 -0
- package/dist/blueprints/GLO_salesOrderLine.js +34 -0
- package/dist/blueprints/GLO_stockCorrection.d.ts +14 -0
- package/dist/blueprints/GLO_stockCorrection.js +24 -0
- package/dist/blueprints/GLO_stopSchedule.d.ts +4 -0
- package/dist/blueprints/GLO_stopSchedule.js +4 -0
- package/dist/blueprints/GLO_supplierArticle.d.ts +8 -0
- package/dist/blueprints/GLO_supplierArticle.js +8 -0
- package/dist/blueprints/GLO_tclSetting.d.ts +6 -0
- package/dist/blueprints/GLO_tclSetting.js +6 -0
- package/dist/blueprints/GLO_todo.d.ts +10 -0
- package/dist/blueprints/GLO_todo.js +21 -0
- package/dist/blueprints/GLO_todoSchedule.d.ts +23 -0
- package/dist/blueprints/GLO_todoSchedule.js +79 -0
- package/dist/blueprints/GLO_tractionDistance.d.ts +6 -0
- package/dist/blueprints/GLO_tractionDistance.js +6 -0
- package/dist/blueprints/GLO_transportOrder.d.ts +4 -0
- package/dist/blueprints/GLO_transportOrder.js +4 -0
- package/dist/blueprints/GLO_transportPrice.d.ts +18 -0
- package/dist/blueprints/GLO_transportPrice.js +34 -0
- package/dist/blueprints/GLO_transportRequirement.d.ts +18 -0
- package/dist/blueprints/GLO_transportRequirement.js +32 -0
- package/dist/blueprints/GLO_transportRequirementContainer.d.ts +8 -0
- package/dist/blueprints/GLO_transportRequirementContainer.js +8 -0
- package/dist/blueprints/GLO_transportRequirementGoods.d.ts +33 -0
- package/dist/blueprints/GLO_transportRequirementGoods.js +51 -0
- package/dist/blueprints/GLO_transportScenarioExecutionLeg.d.ts +6 -0
- package/dist/blueprints/GLO_transportScenarioExecutionLeg.js +6 -0
- package/dist/blueprints/GLO_transportStock.d.ts +4 -0
- package/dist/blueprints/GLO_transportStock.js +4 -0
- package/dist/blueprints/GLO_visit.d.ts +9 -0
- package/dist/blueprints/GLO_visit.js +10 -0
- package/dist/blueprints/GLO_warehouseMove.d.ts +9 -0
- package/dist/blueprints/GLO_warehouseMove.js +9 -0
- package/dist/blueprints/GLO_warehouseOrderSubline.d.ts +6 -0
- package/dist/blueprints/GLO_warehouseOrderSubline.js +11 -0
- package/dist/blueprints/GLO_workflowLoggingTable.d.ts +20 -0
- package/dist/blueprints/GLO_workflowLoggingTable.js +20 -0
- package/dist/blueprints/GLO_workflowVerification.d.ts +10 -0
- package/dist/blueprints/GLO_workflowVerification.js +19 -0
- package/dist/blueprints/GLO_zone.d.ts +9 -0
- package/dist/blueprints/GLO_zone.js +9 -0
- package/dist/blueprints/GLO_zone_postalcode.d.ts +12 -0
- package/dist/blueprints/GLO_zone_postalcode.js +10 -0
- package/dist/blueprints/GLO_zone_type.d.ts +8 -0
- package/dist/blueprints/GLO_zone_type.js +7 -0
- package/dist/blueprints/GLT_goods.d.ts +2 -0
- package/dist/blueprints/GLT_goods.js +12 -0
- package/dist/commands/FOB_commands.d.ts +124 -0
- package/dist/commands/FOB_commands.js +211 -0
- package/dist/commands/GLJ_commands.d.ts +28 -0
- package/dist/commands/GLJ_commands.js +40 -0
- package/dist/commands/GLO_commands.d.ts +685 -0
- package/dist/commands/GLO_commands.js +2315 -0
- package/dist/commands/GLS_commands.d.ts +63 -0
- package/dist/commands/GLS_commands.js +103 -0
- package/dist/commands/GLT_commands.d.ts +78 -0
- package/dist/commands/GLT_commands.js +148 -0
- package/dist/commands/POR_commands.d.ts +85 -0
- package/dist/commands/POR_commands.js +122 -0
- package/dist/config.d.ts +9 -0
- package/dist/config.js +115 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +15 -0
- package/dist/plugins/prometheusWriter.d.ts +15 -0
- package/dist/plugins/prometheusWriter.js +111 -0
- package/dist/register.d.ts +8 -0
- package/dist/register.js +23 -0
- package/dist/tsHelper.d.ts +161 -0
- package/dist/tsHelper.js +294 -0
- package/dist/types.d.ts +363 -0
- package/dist/types.js +4 -0
- package/dist/websocketDetector.d.ts +71 -0
- package/dist/websocketDetector.js +1593 -0
- package/package.json +58 -0
- package/scripts/check-unnecessary-waiting.mjs +119 -0
- package/template/CODEOWNERS +20 -0
- package/template/biome.json +7 -0
- package/template/cypress/e2e/__CUSTOMER__/template.cy.ts.example +18 -0
- package/template/cypress/support/e2e.ts +21 -0
- package/template/cypress/support/record.mjs +46 -0
- package/template/cypress.config.ts +3 -0
- package/template/cypress.env.json.example +24 -0
- package/template/package.json +20 -0
- package/template/tsconfig.json +11 -0
|
@@ -0,0 +1,1593 @@
|
|
|
1
|
+
import { cypressWebsocketDebug, extraParams } from './tsHelper.js';
|
|
2
|
+
/** Module-level flag controlling auto-dismissal of Info dialogs. Disabled by default; set via WS_toggleDialogHandler or env wsEnableDialogHandler=true. */
|
|
3
|
+
let wsDialogHandlerEnabled = extraParams.wsEnableDialogHandler === 'true';
|
|
4
|
+
// ─── Cypress action tracking for metric labels ────────────────────────────────
|
|
5
|
+
const ACTION_COMMANDS = new Set([
|
|
6
|
+
'click',
|
|
7
|
+
'dblclick',
|
|
8
|
+
'rightclick',
|
|
9
|
+
'type',
|
|
10
|
+
'check',
|
|
11
|
+
'uncheck',
|
|
12
|
+
'select',
|
|
13
|
+
'clear',
|
|
14
|
+
'trigger',
|
|
15
|
+
'submit',
|
|
16
|
+
'realClick',
|
|
17
|
+
'realType',
|
|
18
|
+
'realPress',
|
|
19
|
+
'realHover'
|
|
20
|
+
]);
|
|
21
|
+
const LOCATOR_COMMANDS = new Set(['get', 'find', 'contains', 'within']);
|
|
22
|
+
// Commands that are utility/infrastructure — not meaningful as metric labels
|
|
23
|
+
const SKIP_CUSTOM_COMMANDS = new Set(['GLO_checkLoadingIndicator', 'GLO_log', 'GLO_getElement', 'WS_loadingCheck', 'WS_visitWithSpy', 'WS_toggleDialogHandler']);
|
|
24
|
+
// Auto-register every custom command added via Cypress.Commands.add
|
|
25
|
+
const customCommands = new Set();
|
|
26
|
+
const _origCommandsAdd = Cypress.Commands.add.bind(Cypress.Commands);
|
|
27
|
+
Cypress.Commands.add = (name, ...rest) => {
|
|
28
|
+
if (typeof name === 'string')
|
|
29
|
+
customCommands.add(name);
|
|
30
|
+
// biome-ignore lint/suspicious/noExplicitAny: rebinding Cypress.Commands.add's variadic signature isn't expressible without any
|
|
31
|
+
_origCommandsAdd(name, ...rest);
|
|
32
|
+
};
|
|
33
|
+
let lastCypressAction = '';
|
|
34
|
+
let lastCypressLocator = '';
|
|
35
|
+
let lastGLOCommand = '';
|
|
36
|
+
let lastActionStartTime = null;
|
|
37
|
+
let wsInteractionCounter = 0;
|
|
38
|
+
Cypress.on('command:start', (cmd) => {
|
|
39
|
+
const rawName = cmd.get('name');
|
|
40
|
+
const name = typeof rawName === 'string' ? rawName : '';
|
|
41
|
+
const args = (cmd.get('args') ?? []);
|
|
42
|
+
if (LOCATOR_COMMANDS.has(name) && typeof args[0] === 'string')
|
|
43
|
+
lastCypressLocator = args[0];
|
|
44
|
+
if (ACTION_COMMANDS.has(name)) {
|
|
45
|
+
lastCypressAction = name;
|
|
46
|
+
lastActionStartTime = Date.now();
|
|
47
|
+
}
|
|
48
|
+
if (customCommands.has(name) && !SKIP_CUSTOM_COMMANDS.has(name)) {
|
|
49
|
+
const label = args.filter((a) => typeof a === 'string').join(':');
|
|
50
|
+
lastGLOCommand = label ? `${name}:${label}` : name;
|
|
51
|
+
}
|
|
52
|
+
});
|
|
53
|
+
beforeEach(() => {
|
|
54
|
+
wsDialogHandlerEnabled = extraParams.wsEnableDialogHandler === 'true';
|
|
55
|
+
lastCypressAction = '';
|
|
56
|
+
lastCypressLocator = '';
|
|
57
|
+
lastGLOCommand = '';
|
|
58
|
+
lastActionStartTime = null;
|
|
59
|
+
wsInteractionCounter = 0;
|
|
60
|
+
});
|
|
61
|
+
after(() => {
|
|
62
|
+
cy.task('flushWsMetrics', null, { log: false }).then((result) => {
|
|
63
|
+
const r = result;
|
|
64
|
+
let msg;
|
|
65
|
+
if (r.error)
|
|
66
|
+
msg = `❌ metrics flush failed: ${r.error}`;
|
|
67
|
+
else if (r.skipped)
|
|
68
|
+
msg = `⏭️ metrics flush skipped: ${r.skipped}`;
|
|
69
|
+
else
|
|
70
|
+
msg = `✅ pushed ${r.flushed} metrics to Prometheus`;
|
|
71
|
+
Cypress.log({ name: 'WSMetrics', displayName: '📊 WSMetrics', message: msg });
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
Cypress.Commands.add('WS_visitWithSpy', (url) => {
|
|
75
|
+
const enableSpy = () => ({
|
|
76
|
+
onBeforeLoad(win) {
|
|
77
|
+
attachWebSocketSpy(win);
|
|
78
|
+
}
|
|
79
|
+
});
|
|
80
|
+
cy.visit(url, enableSpy());
|
|
81
|
+
cy.window().then((win) => {
|
|
82
|
+
cy.wrap(win.wsMessages).as('wsMessages');
|
|
83
|
+
});
|
|
84
|
+
// Wait for WS to connect before checking load state
|
|
85
|
+
cy.window({ timeout: 60000 }).should((win) => {
|
|
86
|
+
assert.isTrue(win.wsMessages?.wsConnected === true);
|
|
87
|
+
});
|
|
88
|
+
cy.WS_loadingCheck();
|
|
89
|
+
});
|
|
90
|
+
/**
|
|
91
|
+
* Patches window.WebSocket to mirror every outgoing and incoming frame into `win.wsMessages`.
|
|
92
|
+
* Must be called from onBeforeLoad so it runs before the application code connects.
|
|
93
|
+
*
|
|
94
|
+
* @param window - The AUT window object.
|
|
95
|
+
*/
|
|
96
|
+
export function attachWebSocketSpy(window) {
|
|
97
|
+
const messageStore = { messages: [] };
|
|
98
|
+
const OriginalWebSocket = window.WebSocket;
|
|
99
|
+
// biome-ignore lint/complexity/useArrowFunction: must be constructible — the AUT calls `new WebSocket(...)`
|
|
100
|
+
window.WebSocket = function (url, protocols) {
|
|
101
|
+
const socketInstance = protocols ? new OriginalWebSocket(url, protocols) : new OriginalWebSocket(url);
|
|
102
|
+
const originalSendMethod = socketInstance.send.bind(socketInstance);
|
|
103
|
+
// Override send to capture outgoing frames (skip ping 'p' frames)
|
|
104
|
+
socketInstance.send = function (data) {
|
|
105
|
+
if (data !== 'p')
|
|
106
|
+
messageStore.messages.push({ direction: 'outgoing', data, timestamp: Date.now() });
|
|
107
|
+
originalSendMethod.call(this, data);
|
|
108
|
+
};
|
|
109
|
+
socketInstance.addEventListener('open', () => {
|
|
110
|
+
messageStore.wsConnected = true;
|
|
111
|
+
});
|
|
112
|
+
// Capture incoming frames (skip pong 'P' frames)
|
|
113
|
+
socketInstance.addEventListener('message', (event) => {
|
|
114
|
+
if (event.data !== 'P')
|
|
115
|
+
messageStore.messages.push({ direction: 'incoming', data: event.data, timestamp: Date.now() });
|
|
116
|
+
});
|
|
117
|
+
return socketInstance;
|
|
118
|
+
};
|
|
119
|
+
window.wsMessages = messageStore;
|
|
120
|
+
window.wsMetrics = [];
|
|
121
|
+
}
|
|
122
|
+
Cypress.Commands.add('WS_toggleDialogHandler', (mode) => {
|
|
123
|
+
wsDialogHandlerEnabled = mode === 'enable';
|
|
124
|
+
Cypress.log({
|
|
125
|
+
name: 'WSDialogHandler',
|
|
126
|
+
displayName: '🔧 WSDialogHandler',
|
|
127
|
+
message: `Auto info dialog handling: ${mode === 'enable' ? 'enabled' : 'disabled'}`,
|
|
128
|
+
consoleProps: () => ({ mode, disabled: mode === 'disable' })
|
|
129
|
+
});
|
|
130
|
+
});
|
|
131
|
+
Cypress.Commands.add('WS_loadingCheck', (options) => {
|
|
132
|
+
const config = {
|
|
133
|
+
timeout: 120000,
|
|
134
|
+
debugMode: cypressWebsocketDebug,
|
|
135
|
+
...options
|
|
136
|
+
};
|
|
137
|
+
const DEBUG_MODE = config.debugMode;
|
|
138
|
+
// ─── Timing constants ────────────────────────────────────────────────────
|
|
139
|
+
/** Normal quiet period: all signals must stay idle for this long before resolving. */
|
|
140
|
+
const QUIET_PERIOD_DEFAULT_MS = 200;
|
|
141
|
+
/** Extended quiet period used after closeNode or dialog interactions that cause follow-up traffic. */
|
|
142
|
+
const QUIET_PERIOD_EXTENDED_MS = 500;
|
|
143
|
+
/** Extra quiet period when inside a popup form that was opened before this loader started. */
|
|
144
|
+
const POPUP_QUIET_PERIOD_MS = 2000;
|
|
145
|
+
/** How long to wait after a window destroy event before continuing (server follow-up traffic). */
|
|
146
|
+
const WINDOW_DESTROY_WAIT_MS = 500;
|
|
147
|
+
// ─── Dialog constants ─────────────────────────────────────────────────────
|
|
148
|
+
/** Max retries for auto-handling a locked-record dialog before giving up. */
|
|
149
|
+
const LOCKED_RECORD_MAX_RETRIES = 3;
|
|
150
|
+
/** How long to wait before clicking OK on a locked-record dialog (server releases the lock). */
|
|
151
|
+
const LOCKED_RECORD_WAIT_MS = 60000;
|
|
152
|
+
/** How long after a dialog opens to still attribute a formLoaded event to a cellClick. */
|
|
153
|
+
const CELL_CLICK_DIALOG_TIMEOUT_MS = 5000;
|
|
154
|
+
// ─── SvyPush resolution timeouts ─────────────────────────────────────────
|
|
155
|
+
/** Timeout for simple boolean toggles (0/1) — these don't need a server round-trip. */
|
|
156
|
+
const SVY_PUSH_SIMPLE_TOGGLE_TIMEOUT_MS = 100;
|
|
157
|
+
/** Fallback timeout for svyPush changes that never get a server echo. */
|
|
158
|
+
const SVY_PUSH_DEFAULT_TIMEOUT_MS = 12000;
|
|
159
|
+
/** Short delay after hideLoading to auto-resolve associated svyPush entries. */
|
|
160
|
+
const SVY_PUSH_LOADING_RESOLVE_DELAY_MS = 50;
|
|
161
|
+
/** How long to wait after hideLoading for a `_dlg` form to send formUnloaded before timing out. */
|
|
162
|
+
const DLG_SVYPUSH_CLOSE_WAIT_MS = 1000;
|
|
163
|
+
// ─── Form load timeout ────────────────────────────────────────────────────
|
|
164
|
+
/** Max time to wait for a popup form's formLoaded event before giving up. */
|
|
165
|
+
const FORM_LOAD_TIMEOUT_MS = 15000;
|
|
166
|
+
/** Unix timestamp when this loader invocation started — used to filter stale popup messages. */
|
|
167
|
+
const wsLoaderStartTime = Date.now();
|
|
168
|
+
// ─── Test context labels ─────────────────────────────────────────────────
|
|
169
|
+
const testTitle = Cypress.currentTest?.titlePath?.join(' > ') ?? '';
|
|
170
|
+
const specFile = Cypress.spec?.relative ?? '';
|
|
171
|
+
// ─── Interaction timing ──────────────────────────────────────────────────
|
|
172
|
+
/** Timestamp of the action command (click/type/…) that triggered this loader. Null when called without a prior action (e.g. WS_visitWithSpy). */
|
|
173
|
+
const actionStartTime = lastActionStartTime;
|
|
174
|
+
lastActionStartTime = null;
|
|
175
|
+
const interactionId = String(++wsInteractionCounter);
|
|
176
|
+
/** Timestamp when the DOM loading indicator first appeared in this invocation. */
|
|
177
|
+
let capturedFirstLoadingStartTime = null;
|
|
178
|
+
/** Timestamp when the DOM loading indicator last disappeared. */
|
|
179
|
+
let capturedLastLoadingEndTime = null;
|
|
180
|
+
/** Accumulated ms the DOM loading indicator was visible (supports multiple show/hide cycles). */
|
|
181
|
+
let capturedTotalLoadingMs = 0;
|
|
182
|
+
/**
|
|
183
|
+
* Servoy dialog service methods that indicate the server wants to open a message dialog.
|
|
184
|
+
* Note: 'showInputDialog' is handled by the test but listed here for completeness.
|
|
185
|
+
*/
|
|
186
|
+
const DIALOG_METHODS = ['showQuestionDialog', 'showInfoDialog', 'showWarningDialog', 'showErrorDialog', 'showSelectDialog', 'showInputDialog'];
|
|
187
|
+
/**
|
|
188
|
+
* The formService methodnames we track as pending requests.
|
|
189
|
+
* Other methodnames (formLoaded, formUnloaded, etc.) are informational and do not block resolution.
|
|
190
|
+
*/
|
|
191
|
+
const TRACKED_METHODS = ['executeEvent', 'dataPush', 'svyPush', 'callServerSideApi', 'executeInlineScript', 'formvisibility'];
|
|
192
|
+
// ─── Per-invocation mutable state ────────────────────────────────────────
|
|
193
|
+
/** True once a locked-record dialog has been detected in this test (prevents double-detection). */
|
|
194
|
+
let lockedRecordDialogDetected = false;
|
|
195
|
+
/** True once a DOM loading indicator has appeared — extends the effective timeout to the full config value. */
|
|
196
|
+
let domLoadingIndicatorEverDetected = false;
|
|
197
|
+
/** True once the 60s wait has elapsed and the dialog click has been scheduled. */
|
|
198
|
+
let lockedRecordDialogHandled = false;
|
|
199
|
+
/** Monotonically increasing counter for unique dialog keys. */
|
|
200
|
+
let dialogIdCounter = 0;
|
|
201
|
+
/**
|
|
202
|
+
* True once this action's own outgoing dataPush shows a "search" (magnifier) icon click
|
|
203
|
+
* (`handlerExec.eventType` containing "Search"). These open a $windowService popup after a
|
|
204
|
+
* server round-trip with no intermediate WS traffic — treated like being inside a popup so
|
|
205
|
+
* the longer POPUP_QUIET_PERIOD_MS applies instead of racing the default quiet period.
|
|
206
|
+
*/
|
|
207
|
+
let expectSearchPopup = false;
|
|
208
|
+
// ─── Typed helper functions ───────────────────────────────────────────────
|
|
209
|
+
const isFormService = (data) => 'service' in data && 'methodname' in data && data.service === 'formService';
|
|
210
|
+
const hasNonEmptyArray = (arr) => Array.isArray(arr) && arr.length > 0;
|
|
211
|
+
/** Returns all pending requests that are not currently held (waiting for dialog dismissal). */
|
|
212
|
+
const getUnheldRequests = () => [...state.pendingRequests.keys()].filter((id) => !state.heldRequests.has(id));
|
|
213
|
+
// ─── State machine ────────────────────────────────────────────────────────
|
|
214
|
+
const state = {
|
|
215
|
+
pendingRequests: new Map(),
|
|
216
|
+
loadingIndicator: false,
|
|
217
|
+
heldRequests: new Set(),
|
|
218
|
+
pendingDataSync: new Map(),
|
|
219
|
+
lastProcessedIndex: 0,
|
|
220
|
+
lastOutgoingRequestId: null,
|
|
221
|
+
pendingSvyPushMap: new Map(),
|
|
222
|
+
windowDestroyDetected: false,
|
|
223
|
+
windowDestroyTimestamp: null,
|
|
224
|
+
extendedQuietPeriod: false,
|
|
225
|
+
pendingCellClickRequests: new Map(),
|
|
226
|
+
lastDialogCreatedTimestamp: null,
|
|
227
|
+
lastWsActivityTs: Date.now(),
|
|
228
|
+
pendingDialogFormRequests: new Set(),
|
|
229
|
+
popupOpenedByThisAction: false,
|
|
230
|
+
pendingFormLoads: new Set(),
|
|
231
|
+
expectFormShowCount: 0,
|
|
232
|
+
expectingFormLoaded: false,
|
|
233
|
+
navigatingAway: false,
|
|
234
|
+
domLoadingIndicatorStart: null
|
|
235
|
+
};
|
|
236
|
+
/** Open dialogs keyed by `${type}:${counter}`. Window dialogs use `Window:` prefix; message dialogs use `Info:` or `Question:`. */
|
|
237
|
+
const pendingDialogs = new Map();
|
|
238
|
+
/** Active setTimeout handles — cleared on resolution to prevent stale callbacks. */
|
|
239
|
+
const pendingTimeouts = new Set();
|
|
240
|
+
/** Maps `_dlg` form name → timeout id. Blocks resolution until formUnloaded arrives or the timeout fires. */
|
|
241
|
+
const pendingDlgFormSvyPush = new Map();
|
|
242
|
+
/** Reference to the AUT window, refreshed on each poll tick for use in synchronous callbacks. */
|
|
243
|
+
let currentWin = null;
|
|
244
|
+
/** Context from the most recent tracked outgoing formService message — used as metric labels. */
|
|
245
|
+
let lastRequestContext = { formname: '', beanname: '', methodname: '', dataproviderid: '', eventname: '' };
|
|
246
|
+
/** Per-request context for individual round-trip timing metrics. */
|
|
247
|
+
const requestContextMap = new Map();
|
|
248
|
+
/** Live Cypress log entry for the DOM loading-indicator wait, updated in-place each tick. */
|
|
249
|
+
let domLoadingIndicatorLog = null;
|
|
250
|
+
// Initialise the per-test locked-record retry counter
|
|
251
|
+
cy.window({ log: false }).then((win) => {
|
|
252
|
+
win.lockedRecordRetries ??= 0;
|
|
253
|
+
});
|
|
254
|
+
const log = Cypress.log({
|
|
255
|
+
name: 'WSLoader',
|
|
256
|
+
displayName: '🟠 WSLoader',
|
|
257
|
+
message: 'Initializing...',
|
|
258
|
+
consoleProps: () => ({ state: getStateSnapshot(state), timeout: config.timeout, enableAutoInfoDialog: config.enableAutoInfoDialog })
|
|
259
|
+
});
|
|
260
|
+
/** Logs to the Cypress command log only when DEBUG_MODE is enabled. */
|
|
261
|
+
const debugLog = (message, data) => {
|
|
262
|
+
if (!DEBUG_MODE)
|
|
263
|
+
return;
|
|
264
|
+
Cypress.log({ name: 'WSLoader-Debug', message, consoleProps: () => data || {} });
|
|
265
|
+
};
|
|
266
|
+
/** Safely serialises any value to string, handling circular refs and non-serialisable objects. */
|
|
267
|
+
const safeStringify = (data) => {
|
|
268
|
+
if (typeof data === 'string')
|
|
269
|
+
return data;
|
|
270
|
+
if (data === null || data === undefined)
|
|
271
|
+
return '';
|
|
272
|
+
try {
|
|
273
|
+
return JSON.stringify(data);
|
|
274
|
+
}
|
|
275
|
+
catch {
|
|
276
|
+
return '<unparseable>';
|
|
277
|
+
}
|
|
278
|
+
};
|
|
279
|
+
/** Extracts human-readable debug fields (service, method, form, bean) from a parsed message. */
|
|
280
|
+
const extractDebugInfo = (data) => {
|
|
281
|
+
const debugInfo = {};
|
|
282
|
+
if ('service' in data)
|
|
283
|
+
debugInfo.service = data.service;
|
|
284
|
+
if ('methodname' in data)
|
|
285
|
+
debugInfo.methodname = data.methodname;
|
|
286
|
+
if ('args' in data && data.args) {
|
|
287
|
+
if (data.args.formname)
|
|
288
|
+
debugInfo.formname = data.args.formname;
|
|
289
|
+
if (data.args.event)
|
|
290
|
+
debugInfo.event = data.args.event;
|
|
291
|
+
if (data.args.beanname)
|
|
292
|
+
debugInfo.beanname = data.args.beanname;
|
|
293
|
+
if (data.args.methodName)
|
|
294
|
+
debugInfo.methodName = data.args.methodName;
|
|
295
|
+
}
|
|
296
|
+
return debugInfo;
|
|
297
|
+
};
|
|
298
|
+
/**
|
|
299
|
+
* Emits an interaction-scoped metric with context labels automatically attached.
|
|
300
|
+
* Extend future per-request timing by passing extra labels, e.g. { request_id, request_type }.
|
|
301
|
+
*/
|
|
302
|
+
function pushInteractionMetric(win, name, value, extraLabels = {}) {
|
|
303
|
+
if (!win.wsMetrics)
|
|
304
|
+
return;
|
|
305
|
+
win.wsMetrics.push({
|
|
306
|
+
name,
|
|
307
|
+
value,
|
|
308
|
+
labels: {
|
|
309
|
+
hostname: win.location.hostname,
|
|
310
|
+
cypress_action: lastGLOCommand || (lastCypressAction ? `${lastCypressAction}:${lastCypressLocator}` : ''),
|
|
311
|
+
action_type: lastCypressAction,
|
|
312
|
+
...lastRequestContext,
|
|
313
|
+
interaction_id: interactionId,
|
|
314
|
+
test_title: testTitle,
|
|
315
|
+
spec_file: specFile,
|
|
316
|
+
...extraLabels
|
|
317
|
+
},
|
|
318
|
+
timestamp: Date.now()
|
|
319
|
+
});
|
|
320
|
+
}
|
|
321
|
+
/** Builds the standard debug payload logged alongside dialog events. */
|
|
322
|
+
const createDialogDebugInfo = (api) => {
|
|
323
|
+
return {
|
|
324
|
+
args: api.args ?? [],
|
|
325
|
+
heldRequests: Array.from(state.heldRequests),
|
|
326
|
+
lastOutgoingRequestId: state.lastOutgoingRequestId
|
|
327
|
+
};
|
|
328
|
+
};
|
|
329
|
+
debugLog('🔵 WebSocket loader initialized', { timeout: config.timeout, enableAutoInfoDialogOption: config.enableAutoInfoDialog });
|
|
330
|
+
return waitForResolution();
|
|
331
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
332
|
+
// Resolution logic
|
|
333
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
334
|
+
/**
|
|
335
|
+
* Returns true when the WebSocket state machine considers the application idle.
|
|
336
|
+
* All conditions must pass simultaneously; if any returns false the loop keeps polling.
|
|
337
|
+
*/
|
|
338
|
+
function isStateResolved() {
|
|
339
|
+
// Cross-origin navigation detected — nothing more to wait for
|
|
340
|
+
if (state.navigatingAway)
|
|
341
|
+
return true;
|
|
342
|
+
const unheldRequests = getUnheldRequests();
|
|
343
|
+
// After a window/popup is destroyed, wait a short period for follow-up server traffic
|
|
344
|
+
if (state.windowDestroyDetected && state.windowDestroyTimestamp && Date.now() - state.windowDestroyTimestamp < WINDOW_DESTROY_WAIT_MS)
|
|
345
|
+
return false;
|
|
346
|
+
// A popup form opened by this action must fire formLoaded before we resolve
|
|
347
|
+
if (state.pendingFormLoads.size > 0)
|
|
348
|
+
return false;
|
|
349
|
+
// $sabloService told us a form will be shown — wait for the controller update
|
|
350
|
+
if (state.expectFormShowCount > 0)
|
|
351
|
+
return false;
|
|
352
|
+
// After expectFormShowCount reaches 0, wait for the formLoaded confirmation
|
|
353
|
+
if (state.expectingFormLoaded)
|
|
354
|
+
return false;
|
|
355
|
+
// A _dlg form sent a svyPush; wait for its formUnloaded (or a short fallback timeout)
|
|
356
|
+
if (pendingDlgFormSvyPush.size > 0)
|
|
357
|
+
return false;
|
|
358
|
+
const autoInfoDisabled = !(config.enableAutoInfoDialog ?? wsDialogHandlerEnabled);
|
|
359
|
+
// Non-Question, non-Window dialogs (i.e. plain Info dialogs we auto-handle) block the loader.
|
|
360
|
+
// Skip blocking when auto-handling is disabled — the test will handle the dialog.
|
|
361
|
+
if (!autoInfoDisabled && hasAutoHandledPendingDialog())
|
|
362
|
+
return false;
|
|
363
|
+
// Question/Window/Info dialogs may orphan pending requests — unblock so the test can dismiss them.
|
|
364
|
+
if (shouldUnblockForQuestionDialog(unheldRequests))
|
|
365
|
+
return true;
|
|
366
|
+
if (shouldUnblockForWindowDialog(unheldRequests))
|
|
367
|
+
return true;
|
|
368
|
+
if (autoInfoDisabled && shouldUnblockForDisabledInfoDialog(unheldRequests))
|
|
369
|
+
return true;
|
|
370
|
+
// All three signals must be idle: no unheld requests, no loading indicator, no pending data sync
|
|
371
|
+
return unheldRequests.length === 0 && !state.loadingIndicator && state.pendingDataSync.size === 0;
|
|
372
|
+
}
|
|
373
|
+
function hasAutoHandledPendingDialog() {
|
|
374
|
+
return [...pendingDialogs.keys()].some((k) => !k.startsWith('Window:') && !k.startsWith('Question:'));
|
|
375
|
+
}
|
|
376
|
+
function shouldUnblockForQuestionDialog(unheldRequests) {
|
|
377
|
+
const openDialogs = [...pendingDialogs.entries()].filter(([k]) => k.startsWith('Question:'));
|
|
378
|
+
if (openDialogs.length === 0)
|
|
379
|
+
return false;
|
|
380
|
+
if (unheldRequests.length === 0)
|
|
381
|
+
return true;
|
|
382
|
+
const questionDialogTs = Math.min(...openDialogs.map(([, v]) => v.timestamp));
|
|
383
|
+
if (state.loadingIndicator || state.pendingDataSync.size > 0)
|
|
384
|
+
return false;
|
|
385
|
+
const allPredate = unheldRequests.every((id) => {
|
|
386
|
+
const ts = state.pendingRequests.get(id);
|
|
387
|
+
return ts !== undefined && ts < questionDialogTs;
|
|
388
|
+
});
|
|
389
|
+
if (allPredate)
|
|
390
|
+
debugLog('🔓 Orphaned pre-question-dialog requests detected — unblocking', { orphaned: unheldRequests });
|
|
391
|
+
return allPredate;
|
|
392
|
+
}
|
|
393
|
+
function shouldUnblockForWindowDialog(unheldRequests) {
|
|
394
|
+
if (!state.lastDialogCreatedTimestamp)
|
|
395
|
+
return false;
|
|
396
|
+
if (![...pendingDialogs.keys()].some((k) => k.startsWith('Window:')))
|
|
397
|
+
return false;
|
|
398
|
+
if (state.loadingIndicator || state.pendingDataSync.size > 0)
|
|
399
|
+
return false;
|
|
400
|
+
const allPredate = unheldRequests.length === 0 ||
|
|
401
|
+
unheldRequests.every((id) => {
|
|
402
|
+
const ts = state.pendingRequests.get(id);
|
|
403
|
+
return ts !== undefined && ts < state.lastDialogCreatedTimestamp;
|
|
404
|
+
});
|
|
405
|
+
if (allPredate)
|
|
406
|
+
debugLog('🔓 Orphaned pre-window requests detected — unblocking', { orphaned: unheldRequests });
|
|
407
|
+
return allPredate;
|
|
408
|
+
}
|
|
409
|
+
function shouldUnblockForDisabledInfoDialog(unheldRequests) {
|
|
410
|
+
if (state.pendingDataSync.size > 0)
|
|
411
|
+
return false;
|
|
412
|
+
const openInfoDialogs = [...pendingDialogs.entries()].filter(([k]) => k.startsWith('Info:'));
|
|
413
|
+
if (openInfoDialogs.length === 0)
|
|
414
|
+
return false;
|
|
415
|
+
const infoDialogTs = Math.min(...openInfoDialogs.map(([, v]) => v.timestamp));
|
|
416
|
+
const canUnblock = unheldRequests.length === 0 ||
|
|
417
|
+
unheldRequests.every((id) => {
|
|
418
|
+
const ts = state.pendingRequests.get(id);
|
|
419
|
+
return ts !== undefined && ts < infoDialogTs;
|
|
420
|
+
});
|
|
421
|
+
if (canUnblock)
|
|
422
|
+
debugLog('🔓 Info dialog (auto-disabled) — unblocking for test to handle', { dialogs: openInfoDialogs.map(([k]) => k) });
|
|
423
|
+
return canUnblock;
|
|
424
|
+
}
|
|
425
|
+
/**
|
|
426
|
+
* Checks for visible dialogs in the DOM and handles them if appropriate.
|
|
427
|
+
* Returns a string tag: 'null' (no action), 'info', 'noPrice', or 'locked'.
|
|
428
|
+
*/
|
|
429
|
+
function checkForDialogs() {
|
|
430
|
+
return cy.document({ log: false }).then((doc) => {
|
|
431
|
+
const dialog = findDialog(doc);
|
|
432
|
+
if (!dialog.exists)
|
|
433
|
+
return cy.wrap('null', { log: false });
|
|
434
|
+
return cy.window({ log: false }).then((win) => {
|
|
435
|
+
if (dialog.isLocked && !lockedRecordDialogDetected) {
|
|
436
|
+
lockedRecordDialogDetected = true;
|
|
437
|
+
return handleLockedRecordDialog(win, dialog.lockedYesLabel);
|
|
438
|
+
}
|
|
439
|
+
if (dialog.isInfo || dialog.isNoPrice)
|
|
440
|
+
return handleInfoDialog(dialog.isInfo);
|
|
441
|
+
return cy.wrap('null', { log: false });
|
|
442
|
+
});
|
|
443
|
+
});
|
|
444
|
+
}
|
|
445
|
+
/** Inspects the DOM for a `.svy-message-dialog` and classifies it by title/body text. */
|
|
446
|
+
function findDialog(doc) {
|
|
447
|
+
const $dialog = Cypress.$(doc.body).find('.svy-message-dialog');
|
|
448
|
+
if ($dialog.length === 0)
|
|
449
|
+
return { exists: false, isLocked: false, isInfo: false, isNoPrice: false, lockedYesLabel: 'Yes' };
|
|
450
|
+
const title = $dialog.find('.window-title').text();
|
|
451
|
+
const body = $dialog.find('.window-body').text();
|
|
452
|
+
const isLocked = title.includes('Locked record');
|
|
453
|
+
const lockedYesLabel = isLocked ? $dialog.find('.svy-btn-primary').first().text().trim() || 'Yes' : 'Yes';
|
|
454
|
+
return {
|
|
455
|
+
exists: true,
|
|
456
|
+
isLocked,
|
|
457
|
+
isInfo: title.includes('Info'),
|
|
458
|
+
// "No price found" warnings appear in sales flows and are safe to auto-dismiss
|
|
459
|
+
isNoPrice: title.includes('Warning') && body.includes('No price found'),
|
|
460
|
+
lockedYesLabel
|
|
461
|
+
};
|
|
462
|
+
}
|
|
463
|
+
/** Auto-clicks OK on an Info or "No price found" dialog, unless auto-handling is disabled. */
|
|
464
|
+
function handleInfoDialog(isInfo) {
|
|
465
|
+
// DisableAutoInfoDialog only applies to plain Info dialogs — No price found is always handled
|
|
466
|
+
const shouldDisableAutoInfo = isInfo && !(config.enableAutoInfoDialog ?? wsDialogHandlerEnabled);
|
|
467
|
+
if (shouldDisableAutoInfo) {
|
|
468
|
+
debugLog('ℹ️ Info dialog detected, but auto-handling is disabled');
|
|
469
|
+
return cy.wrap('null', { log: false });
|
|
470
|
+
}
|
|
471
|
+
debugLog('ℹ️ Info/No price dialog detected, Self-handling');
|
|
472
|
+
cy.get('.svy-message-dialog button').contains('OK').click();
|
|
473
|
+
return cy.wrap(isInfo ? 'info' : 'noPrice', { log: false });
|
|
474
|
+
}
|
|
475
|
+
/**
|
|
476
|
+
* Waits 60 seconds for the server to release a record lock, then clicks Yes.
|
|
477
|
+
* Retries up to LOCKED_RECORD_MAX_RETRIES times per test before giving up.
|
|
478
|
+
*/
|
|
479
|
+
function handleLockedRecordDialog(win, yesLabel = 'Yes') {
|
|
480
|
+
const retries = win.lockedRecordRetries ?? 0;
|
|
481
|
+
if (retries >= LOCKED_RECORD_MAX_RETRIES) {
|
|
482
|
+
debugLog('🔒 Locked record dialog detected but max retries (3) reached - skipping');
|
|
483
|
+
return cy.wrap('locked', { log: false });
|
|
484
|
+
}
|
|
485
|
+
win.lockedRecordRetries = retries + 1;
|
|
486
|
+
debugLog(`🔍 Locked record dialog detected (attempt ${win.lockedRecordRetries}/3), waiting 60s before handling`);
|
|
487
|
+
// biome-ignore plugin: <internal> Cypress wait for locked record dialog handling
|
|
488
|
+
cy.wait(LOCKED_RECORD_WAIT_MS).then(() => {
|
|
489
|
+
if (!lockedRecordDialogHandled) {
|
|
490
|
+
lockedRecordDialogHandled = true;
|
|
491
|
+
debugLog(`🔓 60s wait complete — executing GLO_dialogRequiredCheck (attempt ${win.lockedRecordRetries}/3)`);
|
|
492
|
+
cy.GLO_dialogRequiredCheck(yesLabel, 'Locked record');
|
|
493
|
+
}
|
|
494
|
+
});
|
|
495
|
+
return cy.wrap('locked', { log: false });
|
|
496
|
+
}
|
|
497
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
498
|
+
// Main polling loop
|
|
499
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
500
|
+
/** Entry point for the resolution loop. Polls every 100 ms until resolved or timed out. */
|
|
501
|
+
function waitForResolution() {
|
|
502
|
+
const startTime = Date.now();
|
|
503
|
+
return checkAndWait();
|
|
504
|
+
function checkAndWait() {
|
|
505
|
+
return cy
|
|
506
|
+
.window({ log: false })
|
|
507
|
+
.then((win) => {
|
|
508
|
+
let hasWsMessages;
|
|
509
|
+
try {
|
|
510
|
+
hasWsMessages = !!win.wsMessages;
|
|
511
|
+
}
|
|
512
|
+
catch {
|
|
513
|
+
hasWsMessages = false;
|
|
514
|
+
}
|
|
515
|
+
// If wsMessages is gone the page navigated away — nothing more to wait for
|
|
516
|
+
if (!hasWsMessages)
|
|
517
|
+
return cy.wrap('navigated', { log: false });
|
|
518
|
+
return checkForDialogs();
|
|
519
|
+
})
|
|
520
|
+
.then((dialogType) => {
|
|
521
|
+
if (dialogType === 'navigated') {
|
|
522
|
+
debugLog('🟢 Page navigated away — resolving loader');
|
|
523
|
+
log.set({ displayName: '🟢 WSLoader', message: 'Resolved' }).end();
|
|
524
|
+
return cy.wrap(undefined, { log: false });
|
|
525
|
+
}
|
|
526
|
+
if (dialogType === 'locked') {
|
|
527
|
+
debugLog('🔓 Resolving loader due to locked record detection');
|
|
528
|
+
log.set({ displayName: '🟡 WSLoader', message: 'Resolved (Locked Record)' }).end();
|
|
529
|
+
return clearWebSocketMessages();
|
|
530
|
+
}
|
|
531
|
+
return performResolutionCheck();
|
|
532
|
+
});
|
|
533
|
+
}
|
|
534
|
+
function performResolutionCheck() {
|
|
535
|
+
return checkResolution().then((isResolved) => {
|
|
536
|
+
if (isResolved)
|
|
537
|
+
return handleResolvedState();
|
|
538
|
+
const effectiveTimeout = !options?.timeout && domLoadingIndicatorEverDetected ? 1200000 : config.timeout;
|
|
539
|
+
if (Date.now() - startTime > effectiveTimeout)
|
|
540
|
+
return handleTimeout(effectiveTimeout);
|
|
541
|
+
// biome-ignore plugin: internal polling interval for checkAndWait retry loop
|
|
542
|
+
return cy.wait(100, { log: false }).then(() => checkAndWait());
|
|
543
|
+
});
|
|
544
|
+
}
|
|
545
|
+
/**
|
|
546
|
+
* Called once isStateResolved() returns true.
|
|
547
|
+
* Enforces the quiet period — the state must stay resolved for QUIET_PERIOD_MS before
|
|
548
|
+
* we actually commit to being done.
|
|
549
|
+
*/
|
|
550
|
+
function handleResolvedState() {
|
|
551
|
+
if (state.navigatingAway) {
|
|
552
|
+
log.set({ displayName: '🟢 WSLoader', message: 'Resolved (navigation)' }).end();
|
|
553
|
+
return clearWebSocketMessages();
|
|
554
|
+
}
|
|
555
|
+
// Inside a popup that was opened before this loader started, use the longer quiet
|
|
556
|
+
// period to bridge the gap between the loading indicator hiding and executeEvent firing
|
|
557
|
+
const isInsideExternalPopup = !state.popupOpenedByThisAction && currentWin?.wsPopupFormOpen === true;
|
|
558
|
+
let quietPeriodMs;
|
|
559
|
+
if (isInsideExternalPopup || expectSearchPopup) {
|
|
560
|
+
quietPeriodMs = POPUP_QUIET_PERIOD_MS;
|
|
561
|
+
}
|
|
562
|
+
else if (state.extendedQuietPeriod) {
|
|
563
|
+
quietPeriodMs = QUIET_PERIOD_EXTENDED_MS;
|
|
564
|
+
}
|
|
565
|
+
else {
|
|
566
|
+
quietPeriodMs = config.quietPeriodMs ?? QUIET_PERIOD_DEFAULT_MS;
|
|
567
|
+
}
|
|
568
|
+
// Only resolve if quiet period has fully elapsed since the last WS activity
|
|
569
|
+
const now = Date.now();
|
|
570
|
+
const quietTimeSoFar = now - state.lastWsActivityTs;
|
|
571
|
+
const remaining = quietPeriodMs - quietTimeSoFar;
|
|
572
|
+
if (remaining > 0) {
|
|
573
|
+
return cy.wait(remaining, { log: false }).then(() => checkAndWait());
|
|
574
|
+
}
|
|
575
|
+
state.extendedQuietPeriod = false;
|
|
576
|
+
log.set({ displayName: '🟢 WSLoader', message: 'Resolved' }).end();
|
|
577
|
+
return clearWebSocketMessages();
|
|
578
|
+
}
|
|
579
|
+
/**
|
|
580
|
+
* Checks whether the DOM loading indicator is gone first, then checks WS state.
|
|
581
|
+
* The DOM indicator guards against Angular render lag that can appear after WS goes idle.
|
|
582
|
+
*/
|
|
583
|
+
function checkResolution() {
|
|
584
|
+
return checkDomLoadingIndicator().then((domReady) => {
|
|
585
|
+
if (!domReady)
|
|
586
|
+
return cy.wrap(false, { log: false });
|
|
587
|
+
return checkWsResolution();
|
|
588
|
+
});
|
|
589
|
+
}
|
|
590
|
+
/** Processes new WS frames and evaluates isStateResolved() in a single cy.window() call. */
|
|
591
|
+
function checkWsResolution() {
|
|
592
|
+
return cy.window({ log: false }).then((win) => {
|
|
593
|
+
currentWin = win;
|
|
594
|
+
let wsMessages;
|
|
595
|
+
try {
|
|
596
|
+
wsMessages = win.wsMessages;
|
|
597
|
+
}
|
|
598
|
+
catch {
|
|
599
|
+
wsMessages = undefined;
|
|
600
|
+
}
|
|
601
|
+
if (!wsMessages) {
|
|
602
|
+
debugLog('🟢 No wsMessages found, resolving');
|
|
603
|
+
return cy.wrap(true, { log: false });
|
|
604
|
+
}
|
|
605
|
+
processMessages(wsMessages);
|
|
606
|
+
const isResolved = isStateResolved();
|
|
607
|
+
let wsPopupFormOpen;
|
|
608
|
+
try {
|
|
609
|
+
wsPopupFormOpen = win.wsPopupFormOpen ?? false;
|
|
610
|
+
}
|
|
611
|
+
catch {
|
|
612
|
+
wsPopupFormOpen = false;
|
|
613
|
+
}
|
|
614
|
+
if (!isResolved && !state.popupOpenedByThisAction && wsPopupFormOpen) {
|
|
615
|
+
debugLog('🪟 Inside popup from previous action — using extended quiet period');
|
|
616
|
+
}
|
|
617
|
+
updateLog(isResolved, startTime);
|
|
618
|
+
return cy.wrap(isResolved, { log: false });
|
|
619
|
+
});
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
623
|
+
// DOM loading indicator check
|
|
624
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
625
|
+
/**
|
|
626
|
+
* Returns false (not ready) while a `<loading-indicator>` element is present in the DOM.
|
|
627
|
+
* Updates a live Cypress log entry showing how long we have been waiting.
|
|
628
|
+
*/
|
|
629
|
+
function checkDomLoadingIndicator() {
|
|
630
|
+
return cy.document({ log: false }).then((doc) => {
|
|
631
|
+
const hasLoadingIndicator = Cypress.$(doc.body).find('loading-indicator').length > 0;
|
|
632
|
+
if (hasLoadingIndicator) {
|
|
633
|
+
if (state.domLoadingIndicatorStart === null) {
|
|
634
|
+
state.domLoadingIndicatorStart = Date.now();
|
|
635
|
+
capturedFirstLoadingStartTime ??= state.domLoadingIndicatorStart;
|
|
636
|
+
domLoadingIndicatorEverDetected = true;
|
|
637
|
+
domLoadingIndicatorLog = Cypress.log({ name: 'WSLoader-DOM', message: '⏳ loading-indicator in DOM — waiting (0.0s)' });
|
|
638
|
+
}
|
|
639
|
+
else if (domLoadingIndicatorLog) {
|
|
640
|
+
const elapsed = ((Date.now() - state.domLoadingIndicatorStart) / 1000).toFixed(1);
|
|
641
|
+
domLoadingIndicatorLog.set('message', `⏳ loading-indicator in DOM — waiting (${elapsed}s)`);
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
else if (state.domLoadingIndicatorStart !== null) {
|
|
645
|
+
const loadingEndTime = Date.now();
|
|
646
|
+
const durationMs = loadingEndTime - state.domLoadingIndicatorStart;
|
|
647
|
+
const duration = (durationMs / 1000).toFixed(1);
|
|
648
|
+
if (domLoadingIndicatorLog) {
|
|
649
|
+
domLoadingIndicatorLog.set('message', `✅ loading-indicator removed after ${duration}s`);
|
|
650
|
+
}
|
|
651
|
+
capturedTotalLoadingMs += durationMs;
|
|
652
|
+
capturedLastLoadingEndTime = loadingEndTime;
|
|
653
|
+
state.domLoadingIndicatorStart = null;
|
|
654
|
+
domLoadingIndicatorLog = null;
|
|
655
|
+
}
|
|
656
|
+
return cy.wrap(!hasLoadingIndicator, { log: false });
|
|
657
|
+
});
|
|
658
|
+
}
|
|
659
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
660
|
+
// Message processing
|
|
661
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
662
|
+
/** Logs all pending state and throws a descriptive error after the timeout is exceeded. */
|
|
663
|
+
function handleTimeout(timeout) {
|
|
664
|
+
const unheldRequests = getUnheldRequests();
|
|
665
|
+
console.group('🚨 WebSocket Timeout Debug Information');
|
|
666
|
+
console.error(`WebSocket timeout after ${timeout}ms`);
|
|
667
|
+
console.log('Pending Requests:', unheldRequests);
|
|
668
|
+
return logAllWebSocketMessages('Timeout').then(() => {
|
|
669
|
+
throw new Error(`WebSocket timeout after ${timeout}ms. Pending: ${unheldRequests.join(', ')}`);
|
|
670
|
+
});
|
|
671
|
+
}
|
|
672
|
+
/**
|
|
673
|
+
* Reads all new frames from the message store (since `state.lastProcessedIndex`),
|
|
674
|
+
* sorts them by timestamp, and routes each to the appropriate outgoing/incoming handler.
|
|
675
|
+
*/
|
|
676
|
+
function processMessages(store) {
|
|
677
|
+
const newMessages = store.messages.slice(state.lastProcessedIndex).sort((a, b) => a.timestamp - b.timestamp);
|
|
678
|
+
state.lastProcessedIndex = store.messages.length;
|
|
679
|
+
for (const message of newMessages) {
|
|
680
|
+
if (isPingPongFrame(message))
|
|
681
|
+
continue;
|
|
682
|
+
markWsActivity();
|
|
683
|
+
try {
|
|
684
|
+
const parsed = parseMessage(message);
|
|
685
|
+
if (!parsed)
|
|
686
|
+
continue;
|
|
687
|
+
if (message.direction === 'outgoing')
|
|
688
|
+
handleOutgoingMessage(parsed, message.timestamp);
|
|
689
|
+
else
|
|
690
|
+
handleIncomingMessage(parsed, message.timestamp);
|
|
691
|
+
}
|
|
692
|
+
catch (e) {
|
|
693
|
+
const messageData = safeStringify(message.data).substring(0, 100);
|
|
694
|
+
debugLog('⚠️ Failed to parse message', { error: e instanceof Error ? e.message : String(e), messageData });
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
}
|
|
698
|
+
/**
|
|
699
|
+
* Processes an outgoing (client → server) frame.
|
|
700
|
+
* Tracks the request id, svyPush changes, cellClicks, dialog interactions, and form lifecycle.
|
|
701
|
+
*/
|
|
702
|
+
function handleOutgoingMessage(data, timestamp) {
|
|
703
|
+
const requestId = extractRequestId(data);
|
|
704
|
+
checkForCloseNode(data);
|
|
705
|
+
checkForDialogInteraction(data);
|
|
706
|
+
checkForSearchIconClick(data);
|
|
707
|
+
trackCellClickRequests(data, requestId, timestamp);
|
|
708
|
+
if (requestId && isTrackedRequest(data))
|
|
709
|
+
trackPendingRequest(data, requestId, timestamp);
|
|
710
|
+
handleFormServiceOutgoing(data);
|
|
711
|
+
}
|
|
712
|
+
/**
|
|
713
|
+
* Detects a list-form-component "search" (magnifier) icon click via the `onSearchActionMethodID`
|
|
714
|
+
* handlerExec event embedded in the field's own outgoing dataPush, e.g.:
|
|
715
|
+
* `{"changes":{"containedForm":[[],[{"handlerExec":{"eventType":"onSearchActionMethodID",...}}]]}}`.
|
|
716
|
+
*/
|
|
717
|
+
function checkForSearchIconClick(data) {
|
|
718
|
+
if (!isFormService(data) || data.methodname !== 'dataPush')
|
|
719
|
+
return;
|
|
720
|
+
const containedForm = data.args?.changes?.containedForm;
|
|
721
|
+
if (!Array.isArray(containedForm))
|
|
722
|
+
return;
|
|
723
|
+
const isSearchClick = containedForm.some((row) => Array.isArray(row) &&
|
|
724
|
+
row.some((cell) => {
|
|
725
|
+
const eventType = cell?.handlerExec?.eventType;
|
|
726
|
+
return typeof eventType === 'string' && eventType.toLowerCase().includes('search');
|
|
727
|
+
}));
|
|
728
|
+
if (isSearchClick) {
|
|
729
|
+
expectSearchPopup = true;
|
|
730
|
+
debugLog('🔍 Search icon click detected — using popup quiet period');
|
|
731
|
+
}
|
|
732
|
+
}
|
|
733
|
+
function trackPendingRequest(data, requestId, timestamp) {
|
|
734
|
+
handleExecuteEvent(data);
|
|
735
|
+
state.pendingRequests.set(requestId, timestamp);
|
|
736
|
+
state.lastOutgoingRequestId = requestId;
|
|
737
|
+
if (isFormService(data)) {
|
|
738
|
+
requestContextMap.set(requestId, {
|
|
739
|
+
methodname: data.methodname,
|
|
740
|
+
formname: data.args?.formname ?? '',
|
|
741
|
+
beanname: data.args?.beanname ?? '',
|
|
742
|
+
dataproviderid: data.methodname === 'svyPush' ? String(data.args?.changes?.dataProviderID ?? '') : '',
|
|
743
|
+
eventname: data.args?.event ?? ''
|
|
744
|
+
});
|
|
745
|
+
}
|
|
746
|
+
debugLog(`📤 Tracking: ${requestId}`, extractDebugInfo(data));
|
|
747
|
+
// ExecuteEvents from _dlg forms are resolved when the window closes, not by a response
|
|
748
|
+
if (isDialogFormEvent(data)) {
|
|
749
|
+
state.pendingDialogFormRequests.add(requestId);
|
|
750
|
+
debugLog(`🔲 Dialog form event tracked: ${requestId}`, extractDebugInfo(data));
|
|
751
|
+
}
|
|
752
|
+
}
|
|
753
|
+
function handleFormServiceOutgoing(data) {
|
|
754
|
+
if (!isFormService(data))
|
|
755
|
+
return;
|
|
756
|
+
if (data.methodname === 'svyPush')
|
|
757
|
+
trackDataChange(data);
|
|
758
|
+
if (data.args?.formname && data.args?.beanname) {
|
|
759
|
+
lastRequestContext = {
|
|
760
|
+
formname: data.args.formname,
|
|
761
|
+
beanname: data.args.beanname,
|
|
762
|
+
methodname: data.methodname,
|
|
763
|
+
dataproviderid: data.methodname === 'svyPush' ? String(data.args.changes?.dataProviderID ?? '') : '',
|
|
764
|
+
eventname: data.methodname === 'executeEvent' ? (data.args.event ?? '') : ''
|
|
765
|
+
};
|
|
766
|
+
}
|
|
767
|
+
if (data.methodname === 'formLoaded')
|
|
768
|
+
handleFormLoaded(data);
|
|
769
|
+
if (data.methodname === 'formUnloaded')
|
|
770
|
+
handleFormUnloaded(data);
|
|
771
|
+
}
|
|
772
|
+
/**
|
|
773
|
+
* Returns true for executeEvent calls on forms whose name contains `_dlg`.
|
|
774
|
+
* These dialog-form requests are resolved by window destruction, not by a normal server response.
|
|
775
|
+
*/
|
|
776
|
+
function isDialogFormEvent(data) {
|
|
777
|
+
if (!isFormService(data) || data.methodname !== 'executeEvent')
|
|
778
|
+
return false;
|
|
779
|
+
const formname = data.args?.formname;
|
|
780
|
+
if (!formname || typeof formname !== 'string')
|
|
781
|
+
return false;
|
|
782
|
+
return formname.includes('_dlg');
|
|
783
|
+
}
|
|
784
|
+
/**
|
|
785
|
+
* Processes an incoming (server → client) frame.
|
|
786
|
+
* Routes it to the dialog or normal response handler, then applies service API side-effects.
|
|
787
|
+
*/
|
|
788
|
+
function handleIncomingMessage(data, timestamp) {
|
|
789
|
+
// ColumnState-only dataPush messages are grid column layout updates — not data changes
|
|
790
|
+
if (isDataPushColumnStateOnly(data)) {
|
|
791
|
+
debugLog('🔁 Ignored columnState-only dataPush');
|
|
792
|
+
return;
|
|
793
|
+
}
|
|
794
|
+
const hasDialog = 'serviceApis' in data && Array.isArray(data.serviceApis) && checkForDialogInResponse(data.serviceApis);
|
|
795
|
+
if (hasDialog) {
|
|
796
|
+
// Response triggers a dialog — hold the triggering request until the dialog is dismissed
|
|
797
|
+
handleDialogResponse(data);
|
|
798
|
+
}
|
|
799
|
+
else {
|
|
800
|
+
handleNormalResponse(data);
|
|
801
|
+
}
|
|
802
|
+
if ('serviceApis' in data && Array.isArray(data.serviceApis)) {
|
|
803
|
+
updateLoadingState(data.serviceApis);
|
|
804
|
+
updateDialogState(data.serviceApis);
|
|
805
|
+
updateExpectFormShowState(data.serviceApis);
|
|
806
|
+
}
|
|
807
|
+
// Check if this response echoes back a pending svyPush data change
|
|
808
|
+
if ('msg' in data && data.msg.forms)
|
|
809
|
+
checkDataSyncReflection(data.msg.forms);
|
|
810
|
+
// Detect popup form opening via the window.popupform service message
|
|
811
|
+
if ('msg' in data)
|
|
812
|
+
checkForPopupFormOpening(data.msg, timestamp);
|
|
813
|
+
}
|
|
814
|
+
/**
|
|
815
|
+
* Detects when the server signals that a popup form is being opened by this action.
|
|
816
|
+
* Records the form name so we can wait for its formLoaded before resolving.
|
|
817
|
+
* Ignores stale popupform signals from before this loader invocation started.
|
|
818
|
+
*/
|
|
819
|
+
function checkForPopupFormOpening(msg, timestamp) {
|
|
820
|
+
const popupFormValue = msg?.services?.window?.popupform?.v;
|
|
821
|
+
if (!popupFormValue?.form)
|
|
822
|
+
return;
|
|
823
|
+
if (timestamp < wsLoaderStartTime)
|
|
824
|
+
return;
|
|
825
|
+
const formname = popupFormValue.form;
|
|
826
|
+
state.popupOpenedByThisAction = true;
|
|
827
|
+
state.pendingFormLoads.add(formname);
|
|
828
|
+
if (currentWin)
|
|
829
|
+
currentWin.wsPopupFormOpen = true;
|
|
830
|
+
debugLog(`🪟 Popup form opening: ${formname} — awaiting formLoaded`, { form: formname });
|
|
831
|
+
// Fallback in case formLoaded never arrives (e.g. the popup was immediately closed)
|
|
832
|
+
const formTid = setTimeout(() => {
|
|
833
|
+
pendingTimeouts.delete(formTid);
|
|
834
|
+
if (state.pendingFormLoads.has(formname)) {
|
|
835
|
+
state.pendingFormLoads.delete(formname);
|
|
836
|
+
debugLog(`⏱️ Form load timeout: ${formname}`);
|
|
837
|
+
}
|
|
838
|
+
}, FORM_LOAD_TIMEOUT_MS);
|
|
839
|
+
pendingTimeouts.add(formTid);
|
|
840
|
+
}
|
|
841
|
+
/** Sets extendedQuietPeriod when a closeNode call is detected (tree collapse triggers follow-up traffic). */
|
|
842
|
+
function checkForCloseNode(data) {
|
|
843
|
+
if (!isFormService(data) || data.methodname !== 'callServerSideApi' || data.args?.methodName !== 'closeNode')
|
|
844
|
+
return;
|
|
845
|
+
state.extendedQuietPeriod = true;
|
|
846
|
+
debugLog('🚪 closeNode detected - extending quiet period to 500ms', extractDebugInfo(data));
|
|
847
|
+
}
|
|
848
|
+
/**
|
|
849
|
+
* Detects when the test (or auto-handler) dismisses a dialog via a smsgid+ret reply.
|
|
850
|
+
* Releases the held request so the loader keeps waiting for the server's actual response.
|
|
851
|
+
*/
|
|
852
|
+
function checkForDialogInteraction(data) {
|
|
853
|
+
if (!('smsgid' in data && 'ret' in data && typeof data.ret === 'string'))
|
|
854
|
+
return;
|
|
855
|
+
state.extendedQuietPeriod = true;
|
|
856
|
+
// Release the oldest held non-Window pending dialog (FIFO order)
|
|
857
|
+
for (const [key, dialogData] of pendingDialogs) {
|
|
858
|
+
if (!key.startsWith('Window:')) {
|
|
859
|
+
pendingDialogs.delete(key);
|
|
860
|
+
if (dialogData.triggeringRequestId && state.heldRequests.has(dialogData.triggeringRequestId)) {
|
|
861
|
+
state.heldRequests.delete(dialogData.triggeringRequestId);
|
|
862
|
+
debugLog(`🔓 Released held request after dialog interaction: ${dialogData.triggeringRequestId}`);
|
|
863
|
+
}
|
|
864
|
+
break;
|
|
865
|
+
}
|
|
866
|
+
}
|
|
867
|
+
debugLog(`🗨️ Outgoing dialog interaction detected (smsgid: ${data.smsgid}, ret: "${data.ret.trim()}") — extending quiet period`);
|
|
868
|
+
}
|
|
869
|
+
/** Tracks cellClick requests separately so they can be resolved by a subsequent formLoaded event. */
|
|
870
|
+
function trackCellClickRequests(data, requestId, timestamp) {
|
|
871
|
+
if (!requestId || !isFormService(data) || data.methodname !== 'callServerSideApi' || data.args?.methodName !== 'cellClick')
|
|
872
|
+
return;
|
|
873
|
+
const argsArray = data.args.args;
|
|
874
|
+
if (!hasNonEmptyArray(argsArray))
|
|
875
|
+
return;
|
|
876
|
+
const firstArg = argsArray[0];
|
|
877
|
+
if (typeof firstArg === 'string' && (firstArg === 'click' || firstArg === 'doubleClick')) {
|
|
878
|
+
state.pendingCellClickRequests.set(requestId, {
|
|
879
|
+
timestamp,
|
|
880
|
+
clickType: firstArg,
|
|
881
|
+
formname: data.args.formname ?? '',
|
|
882
|
+
beanname: data.args.beanname ?? ''
|
|
883
|
+
});
|
|
884
|
+
debugLog(`🖱️ Tracking cellClick: ${requestId} (${firstArg})`, extractDebugInfo(data));
|
|
885
|
+
}
|
|
886
|
+
}
|
|
887
|
+
/**
|
|
888
|
+
* When an executeEvent arrives for a form/bean that has a pending svyPush, the user has
|
|
889
|
+
* confirmed the data change. The svyPush entry is resolved immediately instead of waiting
|
|
890
|
+
* for the server echo.
|
|
891
|
+
*/
|
|
892
|
+
function handleExecuteEvent(data) {
|
|
893
|
+
if (!('methodname' in data) || data.methodname !== 'executeEvent')
|
|
894
|
+
return;
|
|
895
|
+
const formname = 'args' in data ? data.args?.formname : undefined;
|
|
896
|
+
const beanname = 'args' in data ? data.args?.beanname : undefined;
|
|
897
|
+
if (!formname || !beanname)
|
|
898
|
+
return;
|
|
899
|
+
const beanKey = `${formname}:${beanname}`;
|
|
900
|
+
if (!state.pendingSvyPushMap.has(beanKey))
|
|
901
|
+
return;
|
|
902
|
+
const { originalChangeKey } = state.pendingSvyPushMap.get(beanKey);
|
|
903
|
+
if (state.pendingDataSync.has(originalChangeKey)) {
|
|
904
|
+
const svyData = state.pendingDataSync.get(originalChangeKey);
|
|
905
|
+
state.pendingDataSync.delete(originalChangeKey);
|
|
906
|
+
if (currentWin)
|
|
907
|
+
pushInteractionMetric(currentWin, 'cypress_ws_request_timing_ms', Date.now() - svyData.timestamp, {
|
|
908
|
+
methodname: 'svyPush',
|
|
909
|
+
formname: originalChangeKey.split(':')[0],
|
|
910
|
+
beanname: svyData.beanname ?? '',
|
|
911
|
+
phase: wsRequestPhase(svyData.timestamp),
|
|
912
|
+
request_type: 'svyPush'
|
|
913
|
+
});
|
|
914
|
+
debugLog(`🧹 svyPush resolved by executeEvent: ${originalChangeKey}`);
|
|
915
|
+
}
|
|
916
|
+
state.pendingSvyPushMap.delete(beanKey);
|
|
917
|
+
}
|
|
918
|
+
/**
|
|
919
|
+
* When a response arrives that also opens a dialog, the triggering request is held
|
|
920
|
+
* rather than resolved. The actual result will arrive after the dialog is dismissed.
|
|
921
|
+
*/
|
|
922
|
+
function handleDialogResponse(data) {
|
|
923
|
+
let targetId = null;
|
|
924
|
+
// Prefer exact smsgid match; fall back to the most recent outgoing request
|
|
925
|
+
if ('smsgid' in data && typeof data.smsgid === 'number') {
|
|
926
|
+
const candidate = `cmsg:${data.smsgid}`;
|
|
927
|
+
if (state.pendingRequests.has(candidate))
|
|
928
|
+
targetId = candidate;
|
|
929
|
+
}
|
|
930
|
+
if (!targetId && state.lastOutgoingRequestId && state.pendingRequests.has(state.lastOutgoingRequestId)) {
|
|
931
|
+
targetId = state.lastOutgoingRequestId;
|
|
932
|
+
}
|
|
933
|
+
if (targetId) {
|
|
934
|
+
state.heldRequests.add(targetId);
|
|
935
|
+
debugLog(`🔲 Dialog detected - holding request: ${targetId}`);
|
|
936
|
+
}
|
|
937
|
+
}
|
|
938
|
+
function wsRequestPhase(sendTs) {
|
|
939
|
+
// No loading detected yet at response time → pre_loading
|
|
940
|
+
if (capturedFirstLoadingStartTime === null)
|
|
941
|
+
return 'pre_loading';
|
|
942
|
+
// Request was sent after loading fully ended → post_loading
|
|
943
|
+
if (capturedLastLoadingEndTime !== null && sendTs >= capturedLastLoadingEndTime)
|
|
944
|
+
return 'post_loading';
|
|
945
|
+
// Loading started at some point during the request's lifetime → loading
|
|
946
|
+
return 'loading';
|
|
947
|
+
}
|
|
948
|
+
/** Resolves (removes) a pending request when its matching server response arrives. */
|
|
949
|
+
function handleNormalResponse(data) {
|
|
950
|
+
const requestId = extractRequestId(data);
|
|
951
|
+
if (!requestId)
|
|
952
|
+
return;
|
|
953
|
+
const sendTs = state.pendingRequests.get(requestId);
|
|
954
|
+
const wasHeld = state.heldRequests.has(requestId);
|
|
955
|
+
state.pendingRequests.delete(requestId);
|
|
956
|
+
state.heldRequests.delete(requestId);
|
|
957
|
+
if (sendTs && currentWin) {
|
|
958
|
+
const ctx = requestContextMap.get(requestId);
|
|
959
|
+
requestContextMap.delete(requestId);
|
|
960
|
+
pushInteractionMetric(currentWin, 'cypress_ws_request_timing_ms', Date.now() - sendTs, {
|
|
961
|
+
...(ctx ?? {}),
|
|
962
|
+
request_type: requestId.split(':')[0],
|
|
963
|
+
phase: wsRequestPhase(sendTs)
|
|
964
|
+
});
|
|
965
|
+
}
|
|
966
|
+
debugLog(wasHeld ? `📥 Resolved held request: ${requestId}` : `📥 Resolved: ${requestId}`);
|
|
967
|
+
}
|
|
968
|
+
/**
|
|
969
|
+
* Returns true for dataPush messages that only carry a `columnState` change.
|
|
970
|
+
* These are grid column layout updates triggered by user resizing/reordering — not data changes
|
|
971
|
+
* that need to be tracked.
|
|
972
|
+
*/
|
|
973
|
+
function isDataPushColumnStateOnly(data) {
|
|
974
|
+
if (!isFormService(data) || data.methodname !== 'dataPush')
|
|
975
|
+
return false;
|
|
976
|
+
const args = data.args;
|
|
977
|
+
if (!args || typeof args !== 'object')
|
|
978
|
+
return false;
|
|
979
|
+
const changes = args.changes;
|
|
980
|
+
if (!changes || typeof changes !== 'object')
|
|
981
|
+
return false;
|
|
982
|
+
const changeKeys = Object.keys(changes);
|
|
983
|
+
return changeKeys.length === 1 && changeKeys[0] === 'columnState';
|
|
984
|
+
}
|
|
985
|
+
/**
|
|
986
|
+
* Returns true if this outgoing message should be tracked as a pending request.
|
|
987
|
+
* DOM event triggers (native browser events proxied through formService) are excluded
|
|
988
|
+
* because they do not expect a server response.
|
|
989
|
+
*/
|
|
990
|
+
function isTrackedRequest(data) {
|
|
991
|
+
if (!isFormServiceRequest(data))
|
|
992
|
+
return false;
|
|
993
|
+
if ('methodname' in data && data.methodname === 'executeEvent' && isDOMEventTrigger(data))
|
|
994
|
+
return false;
|
|
995
|
+
return true;
|
|
996
|
+
}
|
|
997
|
+
/** Returns true if the message is a formService call for one of the tracked methodnames. */
|
|
998
|
+
function isFormServiceRequest(data) {
|
|
999
|
+
return isFormService(data) && TRACKED_METHODS.includes(data.methodname);
|
|
1000
|
+
}
|
|
1001
|
+
/**
|
|
1002
|
+
* Returns true when an executeEvent's args contain a native browser event object.
|
|
1003
|
+
* These are proxied DOM events (click, focus, etc.) — the server does not send a response.
|
|
1004
|
+
* Identified by presence of `isTrusted`, `delegateTarget`, or `_defaultPrevented` on the first arg.
|
|
1005
|
+
*/
|
|
1006
|
+
function isDOMEventTrigger(data) {
|
|
1007
|
+
if (!('methodname' in data && 'args' in data) || data.methodname !== 'executeEvent')
|
|
1008
|
+
return false;
|
|
1009
|
+
const args = data.args?.args;
|
|
1010
|
+
if (!hasNonEmptyArray(args))
|
|
1011
|
+
return false;
|
|
1012
|
+
const arg0 = args[0];
|
|
1013
|
+
return arg0 && typeof arg0 === 'object' && ('isTrusted' in arg0 || 'delegateTarget' in arg0 || '_defaultPrevented' in arg0);
|
|
1014
|
+
}
|
|
1015
|
+
/**
|
|
1016
|
+
* Tracks a svyPush data-change as a pending data-sync entry.
|
|
1017
|
+
* Simple boolean toggles (0/1) resolve quickly; all other changes use the default timeout.
|
|
1018
|
+
* A fallback timeout auto-resolves the entry if the server echo never arrives.
|
|
1019
|
+
*/
|
|
1020
|
+
function trackDataChange(data) {
|
|
1021
|
+
if (!isFormService(data) || data.methodname !== 'svyPush')
|
|
1022
|
+
return;
|
|
1023
|
+
const { formname, beanname, changes } = data.args;
|
|
1024
|
+
if (!formname || !beanname || !changes?.dataProviderID)
|
|
1025
|
+
return;
|
|
1026
|
+
const changeKey = `${formname}:${beanname}:${String(changes.dataProviderID)}`;
|
|
1027
|
+
const beanKey = `${formname}:${beanname}`;
|
|
1028
|
+
// DataProviderID of 0 or 1 indicates a simple boolean toggle — resolves faster
|
|
1029
|
+
const isSimpleToggle = changes.dataProviderID === 0 || changes.dataProviderID === 1;
|
|
1030
|
+
state.pendingDataSync.set(changeKey, {
|
|
1031
|
+
formBase: formname.split('_ng')[0],
|
|
1032
|
+
value: changes.dataProviderID,
|
|
1033
|
+
timestamp: Date.now(),
|
|
1034
|
+
isSvyPush: true,
|
|
1035
|
+
beanname
|
|
1036
|
+
});
|
|
1037
|
+
state.pendingSvyPushMap.set(beanKey, {
|
|
1038
|
+
timestamp: Date.now(),
|
|
1039
|
+
originalChangeKey: changeKey
|
|
1040
|
+
});
|
|
1041
|
+
debugLog(`📊 Tracking svyPush: ${changeKey}`, { isSimpleToggle, value: changes.dataProviderID });
|
|
1042
|
+
const timeout = isSimpleToggle ? SVY_PUSH_SIMPLE_TOGGLE_TIMEOUT_MS : SVY_PUSH_DEFAULT_TIMEOUT_MS;
|
|
1043
|
+
const svyTid = setTimeout(() => {
|
|
1044
|
+
pendingTimeouts.delete(svyTid);
|
|
1045
|
+
if (state.pendingDataSync.has(changeKey)) {
|
|
1046
|
+
const svyData = state.pendingDataSync.get(changeKey);
|
|
1047
|
+
state.pendingDataSync.delete(changeKey);
|
|
1048
|
+
if (currentWin)
|
|
1049
|
+
pushInteractionMetric(currentWin, 'cypress_ws_request_timing_ms', timeout, {
|
|
1050
|
+
methodname: 'svyPush',
|
|
1051
|
+
formname: changeKey.split(':')[0],
|
|
1052
|
+
beanname: svyData.beanname ?? '',
|
|
1053
|
+
phase: wsRequestPhase(svyData.timestamp),
|
|
1054
|
+
request_type: 'svyPush'
|
|
1055
|
+
});
|
|
1056
|
+
Cypress.log({ name: 'WSLoader', displayName: '⏱️ WSLoader', message: `svyPush fallback triggered after ${timeout}ms: ${changeKey}` });
|
|
1057
|
+
debugLog(`⏱️ Auto-resolved svyPush after ${timeout}ms: ${changeKey}`);
|
|
1058
|
+
}
|
|
1059
|
+
if (state.pendingSvyPushMap.has(beanKey))
|
|
1060
|
+
state.pendingSvyPushMap.delete(beanKey);
|
|
1061
|
+
}, timeout);
|
|
1062
|
+
pendingTimeouts.add(svyTid);
|
|
1063
|
+
}
|
|
1064
|
+
/**
|
|
1065
|
+
* Updates `state.loadingIndicator` based on $sabloLoadingIndicator service calls.
|
|
1066
|
+
* When the indicator hides, pending svyPush entries on normal forms are auto-resolved
|
|
1067
|
+
* after a short delay. For `_dlg` forms, we first wait for formUnloaded confirmation.
|
|
1068
|
+
*/
|
|
1069
|
+
function resolveSvyPushOnHideLoading() {
|
|
1070
|
+
for (const [syncKey, trackedData] of state.pendingDataSync.entries()) {
|
|
1071
|
+
if (!trackedData.isSvyPush)
|
|
1072
|
+
continue;
|
|
1073
|
+
const formname = syncKey.split(':')[0];
|
|
1074
|
+
if (formname.includes('_dlg')) {
|
|
1075
|
+
// _dlg forms: block resolution until formUnloaded arrives or the timeout fires.
|
|
1076
|
+
// This ensures we don't resolve while the dialog close animation is still running.
|
|
1077
|
+
const existingTid = pendingDlgFormSvyPush.get(formname);
|
|
1078
|
+
if (existingTid !== undefined)
|
|
1079
|
+
clearTimeout(existingTid);
|
|
1080
|
+
const closeTid = setTimeout(() => {
|
|
1081
|
+
pendingDlgFormSvyPush.delete(formname);
|
|
1082
|
+
debugLog(`⏱️ dlg form close wait expired: ${formname}`);
|
|
1083
|
+
}, DLG_SVYPUSH_CLOSE_WAIT_MS);
|
|
1084
|
+
pendingDlgFormSvyPush.set(formname, closeTid);
|
|
1085
|
+
debugLog(`⏳ Waiting for formUnloaded or timeout: ${formname}`);
|
|
1086
|
+
}
|
|
1087
|
+
// Auto-resolve after a short delay — the loading indicator hiding is the echo
|
|
1088
|
+
const loadTid = setTimeout(() => {
|
|
1089
|
+
pendingTimeouts.delete(loadTid);
|
|
1090
|
+
if (state.pendingDataSync.has(syncKey)) {
|
|
1091
|
+
const svyData = state.pendingDataSync.get(syncKey);
|
|
1092
|
+
state.pendingDataSync.delete(syncKey);
|
|
1093
|
+
if (currentWin)
|
|
1094
|
+
pushInteractionMetric(currentWin, 'cypress_ws_request_timing_ms', Date.now() - svyData.timestamp, {
|
|
1095
|
+
methodname: 'svyPush',
|
|
1096
|
+
formname: syncKey.split(':')[0],
|
|
1097
|
+
beanname: svyData.beanname ?? '',
|
|
1098
|
+
phase: wsRequestPhase(svyData.timestamp),
|
|
1099
|
+
request_type: 'svyPush'
|
|
1100
|
+
});
|
|
1101
|
+
debugLog(`🟡 svyPush auto-resolved: ${syncKey}`);
|
|
1102
|
+
}
|
|
1103
|
+
}, SVY_PUSH_LOADING_RESOLVE_DELAY_MS);
|
|
1104
|
+
pendingTimeouts.add(loadTid);
|
|
1105
|
+
}
|
|
1106
|
+
}
|
|
1107
|
+
function updateLoadingState(apis) {
|
|
1108
|
+
const loadingApi = apis.find((api) => api.name === '$sabloLoadingIndicator');
|
|
1109
|
+
if (!loadingApi)
|
|
1110
|
+
return;
|
|
1111
|
+
if (loadingApi.call === 'showLoading') {
|
|
1112
|
+
state.loadingIndicator = true;
|
|
1113
|
+
debugLog('🔄 Loading indicator requested');
|
|
1114
|
+
}
|
|
1115
|
+
else if (loadingApi.call === 'hideLoading') {
|
|
1116
|
+
state.loadingIndicator = false;
|
|
1117
|
+
debugLog('✓ Loading indicator resolved');
|
|
1118
|
+
resolveSvyPushOnHideLoading();
|
|
1119
|
+
}
|
|
1120
|
+
}
|
|
1121
|
+
/**
|
|
1122
|
+
* Tracks $sabloService expectFormToShowOnClient signals.
|
|
1123
|
+
* When count transitions from 1→0 alongside an updateController, we set expectingFormLoaded
|
|
1124
|
+
* to wait for the actual formLoaded confirmation before resolving.
|
|
1125
|
+
*/
|
|
1126
|
+
function updateExpectFormShowState(apis) {
|
|
1127
|
+
const hasUpdateController = apis.some((api) => api.name === '$windowService' && api.call === 'updateController');
|
|
1128
|
+
apis.forEach((api) => {
|
|
1129
|
+
if (api.name !== '$sabloService' || api.call !== 'expectFormToShowOnClient')
|
|
1130
|
+
return;
|
|
1131
|
+
const expecting = Array.isArray(api.args) && api.args[0] === true;
|
|
1132
|
+
if (expecting) {
|
|
1133
|
+
state.expectFormShowCount++;
|
|
1134
|
+
debugLog(`📋 expectFormToShowOnClient: true — count: ${state.expectFormShowCount}`);
|
|
1135
|
+
}
|
|
1136
|
+
else {
|
|
1137
|
+
const newCount = Math.max(0, state.expectFormShowCount - 1);
|
|
1138
|
+
if (newCount === 0 && state.expectFormShowCount > 0 && hasUpdateController)
|
|
1139
|
+
state.expectingFormLoaded = true;
|
|
1140
|
+
state.expectFormShowCount = newCount;
|
|
1141
|
+
debugLog(`📋 expectFormToShowOnClient: false — count: ${state.expectFormShowCount}, expectingFormLoaded: ${state.expectingFormLoaded}`);
|
|
1142
|
+
}
|
|
1143
|
+
});
|
|
1144
|
+
}
|
|
1145
|
+
/** Dispatches serviceApi calls to the appropriate dialog/window handler. */
|
|
1146
|
+
function updateDialogState(apis) {
|
|
1147
|
+
apis.forEach((api) => {
|
|
1148
|
+
if (api.name === 'dialogs')
|
|
1149
|
+
handleMessageDialog(api);
|
|
1150
|
+
if (api.name === '$windowService')
|
|
1151
|
+
handleWindowDialog(api);
|
|
1152
|
+
if (api.name === 'window' && api.call === 'cancelFormPopupInternal')
|
|
1153
|
+
handleWindowDestroy(api);
|
|
1154
|
+
if (api.name === '$applicationService' && api.call === 'showUrl')
|
|
1155
|
+
handleShowUrl(api);
|
|
1156
|
+
});
|
|
1157
|
+
}
|
|
1158
|
+
/**
|
|
1159
|
+
* Detects cross-origin navigation (e.g. Okta SSO redirect) so the loader resolves
|
|
1160
|
+
* before the page switches. Relative URLs (e.g. file downloads) are intentionally ignored.
|
|
1161
|
+
*/
|
|
1162
|
+
function handleShowUrl(api) {
|
|
1163
|
+
const url = Array.isArray(api.args) ? api.args[0] : null;
|
|
1164
|
+
const target = Array.isArray(api.args) ? api.args[1] : null;
|
|
1165
|
+
if (target !== '_self' || typeof url !== 'string')
|
|
1166
|
+
return;
|
|
1167
|
+
if (!/^https?:\/\//.test(url))
|
|
1168
|
+
return;
|
|
1169
|
+
state.navigatingAway = true;
|
|
1170
|
+
debugLog('🚀 showUrl(_self) — resolving loader before cross-origin navigation');
|
|
1171
|
+
}
|
|
1172
|
+
/**
|
|
1173
|
+
* Registers a new pending dialog when the server opens a message dialog.
|
|
1174
|
+
*
|
|
1175
|
+
* Only Info dialogs with the literal title "Info" are auto-handled and block the loader.
|
|
1176
|
+
* All others (Warning, Error, Input, Select, Info with a different title) use a "Question:"
|
|
1177
|
+
* key which unblocks the loader so the test can interact with the dialog.
|
|
1178
|
+
*/
|
|
1179
|
+
function handleMessageDialog(api) {
|
|
1180
|
+
if (!api.call || !DIALOG_METHODS.includes(api.call))
|
|
1181
|
+
return;
|
|
1182
|
+
const dialogType = api.call.replace('show', '').replace('Dialog', '');
|
|
1183
|
+
const wsTitle = Array.isArray(api.args) && typeof api.args[0] === 'string' ? api.args[0] : '';
|
|
1184
|
+
// 'Info' key → loader blocks until auto-handled; 'Question' key → loader unblocks immediately
|
|
1185
|
+
const keyType = dialogType === 'Info' && wsTitle === 'Info' ? 'Info' : 'Question';
|
|
1186
|
+
const dialogKey = `${keyType}:${++dialogIdCounter}`;
|
|
1187
|
+
pendingDialogs.set(dialogKey, { dialogType, triggeringRequestId: state.lastOutgoingRequestId, timestamp: Date.now() });
|
|
1188
|
+
debugLog(`🔲 ${dialogType} dialog opened: ${api.call}`, { ...createDialogDebugInfo(api), dialogKey, wsTitle });
|
|
1189
|
+
}
|
|
1190
|
+
/** Routes $windowService API calls to create/show/destroy handlers. */
|
|
1191
|
+
function handleWindowDialog(api) {
|
|
1192
|
+
if (api.call === 'create')
|
|
1193
|
+
handleWindowCreate(api);
|
|
1194
|
+
else if (api.call === 'show')
|
|
1195
|
+
handleWindowShow(api);
|
|
1196
|
+
else if (api.call === 'destroy')
|
|
1197
|
+
handleWindowDestroy(api);
|
|
1198
|
+
}
|
|
1199
|
+
/** Registers a new pending Window dialog entry when a $windowService 'create' is received. */
|
|
1200
|
+
function handleWindowCreate(api) {
|
|
1201
|
+
const windowName = Array.isArray(api.args) && api.args[0] && typeof api.args[0] === 'string' ? api.args[0] : 'unknown';
|
|
1202
|
+
state.lastDialogCreatedTimestamp = Date.now();
|
|
1203
|
+
const dialogKey = `Window:${windowName}:${++dialogIdCounter}`;
|
|
1204
|
+
pendingDialogs.set(dialogKey, { dialogType: 'Window', triggeringRequestId: state.lastOutgoingRequestId, timestamp: Date.now() });
|
|
1205
|
+
const debugInfo = createDialogDebugInfo(api);
|
|
1206
|
+
debugInfo.pendingCellClicks = Array.from(state.pendingCellClickRequests.keys());
|
|
1207
|
+
debugInfo.dialogKey = dialogKey;
|
|
1208
|
+
debugLog(`🪟 Window dialog created: ${windowName}`, debugInfo);
|
|
1209
|
+
}
|
|
1210
|
+
/**
|
|
1211
|
+
* Handles $windowService 'show' — the server is (re-)displaying a popup form.
|
|
1212
|
+
* Unlike 'create', 'show' args are [windowName, formName, title], so the Angular form name
|
|
1213
|
+
* is at args[1]. The `window.popupform` signal that normally feeds pendingFormLoads does not
|
|
1214
|
+
* arrive for 'show', so we add the form here to block resolution until formLoaded fires.
|
|
1215
|
+
*/
|
|
1216
|
+
function handleWindowShow(api) {
|
|
1217
|
+
const formName = Array.isArray(api.args) && api.args[1] && typeof api.args[1] === 'string' ? api.args[1] : 'unknown';
|
|
1218
|
+
state.lastDialogCreatedTimestamp = Date.now();
|
|
1219
|
+
const dialogKey = `Window:${formName}:${++dialogIdCounter}`;
|
|
1220
|
+
pendingDialogs.set(dialogKey, { dialogType: 'Window', triggeringRequestId: state.lastOutgoingRequestId, timestamp: Date.now() });
|
|
1221
|
+
const debugInfo = createDialogDebugInfo(api);
|
|
1222
|
+
debugInfo.dialogKey = dialogKey;
|
|
1223
|
+
debugLog(`🪟 Window shown: ${formName}`, debugInfo);
|
|
1224
|
+
if (formName !== 'unknown' && !state.pendingFormLoads.has(formName)) {
|
|
1225
|
+
state.popupOpenedByThisAction = true;
|
|
1226
|
+
state.pendingFormLoads.add(formName);
|
|
1227
|
+
if (currentWin)
|
|
1228
|
+
currentWin.wsPopupFormOpen = true;
|
|
1229
|
+
const formTid = setTimeout(() => {
|
|
1230
|
+
pendingTimeouts.delete(formTid);
|
|
1231
|
+
if (state.pendingFormLoads.has(formName)) {
|
|
1232
|
+
state.pendingFormLoads.delete(formName);
|
|
1233
|
+
debugLog(`⏱️ Form load timeout (from window show): ${formName}`);
|
|
1234
|
+
}
|
|
1235
|
+
}, FORM_LOAD_TIMEOUT_MS);
|
|
1236
|
+
pendingTimeouts.add(formTid);
|
|
1237
|
+
debugLog(`🪟 Window show — waiting for formLoaded: ${formName}`);
|
|
1238
|
+
}
|
|
1239
|
+
// 'show' is the server's implicit response to a _dlg form button-click — no smsgid ever
|
|
1240
|
+
// comes for that request. Hold the most-recent still-pending _dlg executeEvent so it
|
|
1241
|
+
// doesn't block unheldRequests while the popup loads; handleWindowDestroy resolves it
|
|
1242
|
+
// on close. We only hold the most-recent candidate to avoid incorrectly holding unrelated
|
|
1243
|
+
// concurrent events (e.g. a "Save" click that already completed in the same invocation).
|
|
1244
|
+
const pendingDlgIds = [...state.pendingDialogFormRequests].filter((id) => state.pendingRequests.has(id) && !state.heldRequests.has(id));
|
|
1245
|
+
const triggeringRequest = pendingDlgIds[pendingDlgIds.length - 1];
|
|
1246
|
+
if (triggeringRequest) {
|
|
1247
|
+
state.heldRequests.add(triggeringRequest);
|
|
1248
|
+
debugLog(`🔲 Show event — holding most-recent dialog form request: ${triggeringRequest}`);
|
|
1249
|
+
}
|
|
1250
|
+
}
|
|
1251
|
+
/**
|
|
1252
|
+
* Handles $windowService 'destroy' and window 'cancelFormPopupInternal' — both mean a
|
|
1253
|
+
* popup/dialog was closed. Resets popup state, resolves any _dlg form requests, and
|
|
1254
|
+
* starts the WINDOW_DESTROY_WAIT_MS cool-down period.
|
|
1255
|
+
*/
|
|
1256
|
+
function handleWindowDestroy(api) {
|
|
1257
|
+
state.windowDestroyDetected = true;
|
|
1258
|
+
state.windowDestroyTimestamp = Date.now();
|
|
1259
|
+
const windowName = Array.isArray(api.args) && api.args[0] && typeof api.args[0] === 'string' ? api.args[0] : 'unknown';
|
|
1260
|
+
if (state.heldRequests.size > 0) {
|
|
1261
|
+
debugLog(`🔓 Dialog/Window closed - ${state.heldRequests.size} held requests will be resolved by subsequent messages`);
|
|
1262
|
+
}
|
|
1263
|
+
// Reset popup-opened flag and clear pending form loads
|
|
1264
|
+
state.popupOpenedByThisAction = false;
|
|
1265
|
+
state.pendingFormLoads.clear();
|
|
1266
|
+
if (currentWin)
|
|
1267
|
+
currentWin.wsPopupFormOpen = false;
|
|
1268
|
+
// Bulk-resolve any _dlg form requests — their responses won't arrive after window close
|
|
1269
|
+
if (state.pendingDialogFormRequests.size > 0) {
|
|
1270
|
+
for (const requestId of state.pendingDialogFormRequests) {
|
|
1271
|
+
const sendTs = state.pendingRequests.get(requestId);
|
|
1272
|
+
state.pendingRequests.delete(requestId);
|
|
1273
|
+
state.heldRequests.delete(requestId);
|
|
1274
|
+
if (sendTs && currentWin) {
|
|
1275
|
+
const ctx = requestContextMap.get(requestId);
|
|
1276
|
+
requestContextMap.delete(requestId);
|
|
1277
|
+
pushInteractionMetric(currentWin, 'cypress_ws_request_timing_ms', Date.now() - sendTs, {
|
|
1278
|
+
...(ctx ?? {}),
|
|
1279
|
+
request_type: requestId.split(':')[0],
|
|
1280
|
+
phase: wsRequestPhase(sendTs)
|
|
1281
|
+
});
|
|
1282
|
+
}
|
|
1283
|
+
debugLog(`✅ Dialog form request resolved by window close: ${requestId}`);
|
|
1284
|
+
}
|
|
1285
|
+
state.pendingDialogFormRequests.clear();
|
|
1286
|
+
}
|
|
1287
|
+
for (const key of pendingDialogs.keys()) {
|
|
1288
|
+
if (key.startsWith('Window:'))
|
|
1289
|
+
pendingDialogs.delete(key);
|
|
1290
|
+
}
|
|
1291
|
+
debugLog(`🔳 Window closed: ${windowName} - starting 500ms wait period`);
|
|
1292
|
+
}
|
|
1293
|
+
/** Returns true if any serviceApi in the array opens a dialog (message dialog or window create). */
|
|
1294
|
+
function checkForDialogInResponse(apis) {
|
|
1295
|
+
return apis.some((api) => (api.name === 'dialogs' && api.call && DIALOG_METHODS.includes(api.call)) || (api.name === '$windowService' && (api.call === 'create' || api.call === 'show')));
|
|
1296
|
+
}
|
|
1297
|
+
/**
|
|
1298
|
+
* Checks whether an incoming form update echoes back a pending svyPush.
|
|
1299
|
+
* Matches by formBase (everything before `_ng` suffix) to handle the Angular form naming.
|
|
1300
|
+
*/
|
|
1301
|
+
function checkDataSyncReflection(forms) {
|
|
1302
|
+
for (const formKey of Object.keys(forms)) {
|
|
1303
|
+
for (const [syncKey, pendingData] of state.pendingDataSync.entries()) {
|
|
1304
|
+
if (formKey.includes(pendingData.formBase)) {
|
|
1305
|
+
state.pendingDataSync.delete(syncKey);
|
|
1306
|
+
if (currentWin)
|
|
1307
|
+
pushInteractionMetric(currentWin, 'cypress_ws_request_timing_ms', Date.now() - pendingData.timestamp, {
|
|
1308
|
+
methodname: 'svyPush',
|
|
1309
|
+
formname: syncKey.split(':')[0],
|
|
1310
|
+
beanname: pendingData.beanname ?? '',
|
|
1311
|
+
phase: wsRequestPhase(pendingData.timestamp),
|
|
1312
|
+
request_type: 'svyPush'
|
|
1313
|
+
});
|
|
1314
|
+
debugLog(`✅ Data sync reflected: ${syncKey}`);
|
|
1315
|
+
}
|
|
1316
|
+
}
|
|
1317
|
+
}
|
|
1318
|
+
}
|
|
1319
|
+
/**
|
|
1320
|
+
* Handles a formLoaded notification from the client.
|
|
1321
|
+
* - Resolves the form from pendingFormLoads (popup form finished loading).
|
|
1322
|
+
* - Clears expectingFormLoaded.
|
|
1323
|
+
* - Resolves cellClick requests when a dialog form loaded shortly after a window was created.
|
|
1324
|
+
*/
|
|
1325
|
+
function handleFormLoaded(data) {
|
|
1326
|
+
const formname = 'args' in data && data.args && typeof data.args === 'object' && 'formname' in data.args ? String(data.args.formname) : '';
|
|
1327
|
+
if (formname && currentWin) {
|
|
1328
|
+
pushInteractionMetric(currentWin, 'cypress_ws_request_timing_ms', Date.now() - wsLoaderStartTime, {
|
|
1329
|
+
methodname: 'formLoaded',
|
|
1330
|
+
formname,
|
|
1331
|
+
phase: wsRequestPhase(Date.now()),
|
|
1332
|
+
request_type: 'formLoaded'
|
|
1333
|
+
});
|
|
1334
|
+
}
|
|
1335
|
+
if (formname && state.pendingFormLoads.has(formname)) {
|
|
1336
|
+
state.pendingFormLoads.delete(formname);
|
|
1337
|
+
debugLog(`✅ Popup form loaded: ${formname}`);
|
|
1338
|
+
}
|
|
1339
|
+
if (state.expectingFormLoaded) {
|
|
1340
|
+
state.expectingFormLoaded = false;
|
|
1341
|
+
debugLog('✅ formLoaded resolved expectingFormLoaded', { formname: formname });
|
|
1342
|
+
}
|
|
1343
|
+
if (!state.lastDialogCreatedTimestamp || state.pendingCellClickRequests.size === 0)
|
|
1344
|
+
return;
|
|
1345
|
+
const timeSinceDialog = Date.now() - state.lastDialogCreatedTimestamp;
|
|
1346
|
+
// Only attribute the formLoaded to cellClick if it arrived within the dialog timeout window
|
|
1347
|
+
if (timeSinceDialog >= CELL_CLICK_DIALOG_TIMEOUT_MS)
|
|
1348
|
+
return;
|
|
1349
|
+
for (const [requestId, clickData] of state.pendingCellClickRequests.entries()) {
|
|
1350
|
+
if (!state.pendingRequests.has(requestId))
|
|
1351
|
+
continue;
|
|
1352
|
+
state.pendingRequests.delete(requestId);
|
|
1353
|
+
state.heldRequests.delete(requestId);
|
|
1354
|
+
state.pendingCellClickRequests.delete(requestId);
|
|
1355
|
+
state.extendedQuietPeriod = true;
|
|
1356
|
+
debugLog(`✅ Resolved cellClick by formLoaded pattern: ${requestId}`, {
|
|
1357
|
+
clickType: clickData.clickType,
|
|
1358
|
+
formname: clickData.formname,
|
|
1359
|
+
timeSinceDialog,
|
|
1360
|
+
formLoaded: 'args' in data && data.args && typeof data.args === 'object' && 'formname' in data.args ? String(data.args.formname) : 'unknown'
|
|
1361
|
+
});
|
|
1362
|
+
}
|
|
1363
|
+
state.lastDialogCreatedTimestamp = null;
|
|
1364
|
+
}
|
|
1365
|
+
/**
|
|
1366
|
+
* Handles a formUnloaded notification — clears any pending svyPush entries for the
|
|
1367
|
+
* unloaded form and confirms a _dlg form close if one is awaiting confirmation.
|
|
1368
|
+
*/
|
|
1369
|
+
function handleFormUnloaded(data) {
|
|
1370
|
+
const formname = 'args' in data && data.args && typeof data.args === 'object' && 'formname' in data.args ? String(data.args.formname) : '';
|
|
1371
|
+
if (!formname)
|
|
1372
|
+
return;
|
|
1373
|
+
if (currentWin) {
|
|
1374
|
+
pushInteractionMetric(currentWin, 'cypress_ws_request_timing_ms', Date.now() - wsLoaderStartTime, {
|
|
1375
|
+
methodname: 'formUnloaded',
|
|
1376
|
+
formname,
|
|
1377
|
+
phase: wsRequestPhase(Date.now()),
|
|
1378
|
+
request_type: 'formUnloaded'
|
|
1379
|
+
});
|
|
1380
|
+
}
|
|
1381
|
+
for (const [syncKey] of state.pendingDataSync.entries()) {
|
|
1382
|
+
if (syncKey.startsWith(`${formname}:`)) {
|
|
1383
|
+
state.pendingDataSync.delete(syncKey);
|
|
1384
|
+
debugLog(`✅ svyPush cleared on formUnloaded: ${syncKey}`);
|
|
1385
|
+
}
|
|
1386
|
+
}
|
|
1387
|
+
// Cancel the _dlg fallback timeout — formUnloaded confirmed the dialog was closed
|
|
1388
|
+
const closeTid = pendingDlgFormSvyPush.get(formname);
|
|
1389
|
+
if (closeTid !== undefined) {
|
|
1390
|
+
clearTimeout(closeTid);
|
|
1391
|
+
pendingDlgFormSvyPush.delete(formname);
|
|
1392
|
+
debugLog(`✅ dlg form close confirmed: ${formname}`);
|
|
1393
|
+
}
|
|
1394
|
+
}
|
|
1395
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
1396
|
+
// Logging helpers
|
|
1397
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
1398
|
+
/** Updates the live Cypress log entry with current pending counts and elapsed time. */
|
|
1399
|
+
function updateLog(isResolved, startTime) {
|
|
1400
|
+
if (isResolved)
|
|
1401
|
+
return;
|
|
1402
|
+
const elapsed = Math.floor((Date.now() - startTime) / 1000);
|
|
1403
|
+
const unheldRequests = getUnheldRequests();
|
|
1404
|
+
const heldRequestsList = Array.from(state.heldRequests);
|
|
1405
|
+
const status = [
|
|
1406
|
+
`${elapsed}s`,
|
|
1407
|
+
unheldRequests.length > 0 ? `Requests: ${unheldRequests.length}` : '',
|
|
1408
|
+
heldRequestsList.length > 0 ? `Held: ${heldRequestsList.length}` : '',
|
|
1409
|
+
state.loadingIndicator ? 'Loading' : '',
|
|
1410
|
+
state.pendingDataSync.size > 0 ? `DataSync: ${state.pendingDataSync.size}` : ''
|
|
1411
|
+
]
|
|
1412
|
+
.filter(Boolean)
|
|
1413
|
+
.join(' | ');
|
|
1414
|
+
log.set({ message: status });
|
|
1415
|
+
}
|
|
1416
|
+
/**
|
|
1417
|
+
* Resets the message store and all active timers after successful resolution.
|
|
1418
|
+
* Optionally dumps all recorded messages to the console first (when DEBUG_MODE is on).
|
|
1419
|
+
*/
|
|
1420
|
+
function clearWebSocketMessages() {
|
|
1421
|
+
return cy
|
|
1422
|
+
.wrap(null, { log: false })
|
|
1423
|
+
.then(() => {
|
|
1424
|
+
if (DEBUG_MODE)
|
|
1425
|
+
return logAllWebSocketMessages('Before Clear');
|
|
1426
|
+
return cy.wrap(null, { log: false });
|
|
1427
|
+
})
|
|
1428
|
+
.then(() => {
|
|
1429
|
+
return cy.window({ log: false }).then((win) => {
|
|
1430
|
+
// Capture before reset — this is the last WS message time, excluding the quiet period wait
|
|
1431
|
+
const resolveTime = state.lastWsActivityTs;
|
|
1432
|
+
if (win.wsMessages) {
|
|
1433
|
+
win.wsMessages.messages = [];
|
|
1434
|
+
state.lastProcessedIndex = 0;
|
|
1435
|
+
state.lastWsActivityTs = Date.now();
|
|
1436
|
+
for (const tid of pendingTimeouts)
|
|
1437
|
+
clearTimeout(tid);
|
|
1438
|
+
pendingTimeouts.clear();
|
|
1439
|
+
for (const tid of pendingDlgFormSvyPush.values())
|
|
1440
|
+
clearTimeout(tid);
|
|
1441
|
+
pendingDlgFormSvyPush.clear();
|
|
1442
|
+
debugLog('🧹 Cleared WebSocket messages');
|
|
1443
|
+
}
|
|
1444
|
+
if (actionStartTime !== null) {
|
|
1445
|
+
const totalMs = resolveTime - actionStartTime;
|
|
1446
|
+
const preLoadingMs = capturedFirstLoadingStartTime !== null ? capturedFirstLoadingStartTime - actionStartTime : totalMs;
|
|
1447
|
+
const postLoadingMs = capturedLastLoadingEndTime !== null ? resolveTime - capturedLastLoadingEndTime : 0;
|
|
1448
|
+
pushInteractionMetric(win, 'cypress_interaction_timing_ms', totalMs, { phase: 'total' });
|
|
1449
|
+
pushInteractionMetric(win, 'cypress_interaction_timing_ms', preLoadingMs, { phase: 'pre_loading' });
|
|
1450
|
+
pushInteractionMetric(win, 'cypress_interaction_timing_ms', capturedTotalLoadingMs, { phase: 'loading' });
|
|
1451
|
+
pushInteractionMetric(win, 'cypress_interaction_timing_ms', postLoadingMs, { phase: 'post_loading' });
|
|
1452
|
+
}
|
|
1453
|
+
if (win.wsMetrics?.length) {
|
|
1454
|
+
const metrics = [...win.wsMetrics];
|
|
1455
|
+
win.wsMetrics = [];
|
|
1456
|
+
cy.task('bufferWsMetrics', metrics, { log: false });
|
|
1457
|
+
}
|
|
1458
|
+
});
|
|
1459
|
+
});
|
|
1460
|
+
}
|
|
1461
|
+
/** Dumps the full recorded message log to the browser console for post-mortem debugging. */
|
|
1462
|
+
function logAllWebSocketMessages(context) {
|
|
1463
|
+
return cy
|
|
1464
|
+
.window({ log: false })
|
|
1465
|
+
.then((win) => {
|
|
1466
|
+
if (!win.wsMessages?.messages?.length) {
|
|
1467
|
+
console.log(`No WebSocket messages to log for: ${context}`);
|
|
1468
|
+
return;
|
|
1469
|
+
}
|
|
1470
|
+
console.group(`🔍 WebSocket Messages - ${context}`);
|
|
1471
|
+
console.log(`Total messages: ${win.wsMessages.messages.length}`);
|
|
1472
|
+
logCurrentState();
|
|
1473
|
+
const outgoing = win.wsMessages.messages.filter((m) => m.direction === 'outgoing');
|
|
1474
|
+
const incoming = win.wsMessages.messages.filter((m) => m.direction === 'incoming');
|
|
1475
|
+
logMessageGroup('📤 Outgoing Messages', outgoing);
|
|
1476
|
+
logMessageGroup('📥 Incoming Messages', incoming);
|
|
1477
|
+
logRecentMessages(win.wsMessages.messages);
|
|
1478
|
+
console.groupEnd();
|
|
1479
|
+
})
|
|
1480
|
+
.then(() => cy.wrap(undefined, { log: false }));
|
|
1481
|
+
}
|
|
1482
|
+
/** Dumps the current state machine snapshot to the console. */
|
|
1483
|
+
function logCurrentState() {
|
|
1484
|
+
console.group('📊 Current State');
|
|
1485
|
+
console.log('Pending Requests:', Array.from(state.pendingRequests.keys()));
|
|
1486
|
+
console.log('Held Requests:', Array.from(state.heldRequests));
|
|
1487
|
+
console.log('Loading Indicator:', state.loadingIndicator);
|
|
1488
|
+
console.log('Pending Data Sync:', Array.from(state.pendingDataSync.entries()));
|
|
1489
|
+
console.log('Pending Cell Click Requests:', Array.from(state.pendingCellClickRequests.entries()));
|
|
1490
|
+
console.log('Window Destroy Detected:', state.windowDestroyDetected);
|
|
1491
|
+
console.log('Extended Quiet Period:', state.extendedQuietPeriod);
|
|
1492
|
+
console.groupEnd();
|
|
1493
|
+
}
|
|
1494
|
+
/** Logs a group of WebSocket messages (outgoing or incoming) to the console. */
|
|
1495
|
+
function logMessageGroup(title, messages) {
|
|
1496
|
+
console.group(`${title} (${messages.length})`);
|
|
1497
|
+
messages.forEach((msg, index) => {
|
|
1498
|
+
console.group(`Message ${index + 1} - ${new Date(msg.timestamp).toISOString()}`);
|
|
1499
|
+
console.log('Raw:', msg.data);
|
|
1500
|
+
try {
|
|
1501
|
+
const parsed = parseMessage(msg);
|
|
1502
|
+
if (parsed)
|
|
1503
|
+
console.log('Parsed:', parsed);
|
|
1504
|
+
}
|
|
1505
|
+
catch (e) {
|
|
1506
|
+
console.log('Parse error:', e);
|
|
1507
|
+
}
|
|
1508
|
+
console.groupEnd();
|
|
1509
|
+
});
|
|
1510
|
+
console.groupEnd();
|
|
1511
|
+
}
|
|
1512
|
+
/** Logs the last 10 frames in chronological order to the console. */
|
|
1513
|
+
function logRecentMessages(messages) {
|
|
1514
|
+
console.group('🕐 Last 10 Messages (Chronological)');
|
|
1515
|
+
const lastMessages = [...messages].sort((a, b) => a.timestamp - b.timestamp).slice(-10);
|
|
1516
|
+
lastMessages.forEach((msg, index) => {
|
|
1517
|
+
const parsed = parseMessage(msg);
|
|
1518
|
+
console.log(`${index + 1}. [${msg.direction}] ${new Date(msg.timestamp).toISOString()}`, parsed ?? msg.data);
|
|
1519
|
+
});
|
|
1520
|
+
console.groupEnd();
|
|
1521
|
+
}
|
|
1522
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
1523
|
+
// Utility functions
|
|
1524
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
1525
|
+
/** Returns true for Servoy ping ('p') and pong ('P') heartbeat frames. */
|
|
1526
|
+
function isPingPongFrame(message) {
|
|
1527
|
+
if (typeof message.data !== 'string')
|
|
1528
|
+
return false;
|
|
1529
|
+
const s = message.data.trim();
|
|
1530
|
+
return /^[Pp](\s+\d+)?$/.test(s);
|
|
1531
|
+
}
|
|
1532
|
+
/** Records the current time as the most recent WS activity timestamp (resets the quiet period). */
|
|
1533
|
+
function markWsActivity() {
|
|
1534
|
+
state.lastWsActivityTs = Date.now();
|
|
1535
|
+
}
|
|
1536
|
+
/**
|
|
1537
|
+
* Parses the raw WebSocket frame data into a typed message object.
|
|
1538
|
+
* Servoy frames may be prefixed with non-JSON characters before the JSON payload.
|
|
1539
|
+
*/
|
|
1540
|
+
function parseMessage(message) {
|
|
1541
|
+
if (typeof message.data !== 'string')
|
|
1542
|
+
return null;
|
|
1543
|
+
const jsonStart = message.data.indexOf('{');
|
|
1544
|
+
const jsonString = jsonStart !== -1 ? message.data.substring(jsonStart) : message.data;
|
|
1545
|
+
try {
|
|
1546
|
+
return JSON.parse(jsonString);
|
|
1547
|
+
}
|
|
1548
|
+
catch {
|
|
1549
|
+
return null;
|
|
1550
|
+
}
|
|
1551
|
+
}
|
|
1552
|
+
/**
|
|
1553
|
+
* Extracts the tracking RequestId from a parsed message.
|
|
1554
|
+
* For outgoing messages: reads cmsgid, requestId, or msgid.
|
|
1555
|
+
* For incoming messages: matches smsgid back to a known pending cmsgid (skips dialog responses).
|
|
1556
|
+
*/
|
|
1557
|
+
function extractRequestId(data) {
|
|
1558
|
+
// Outgoing message id fields
|
|
1559
|
+
if ('cmsgid' in data && data.cmsgid !== undefined)
|
|
1560
|
+
return `cmsg:${data.cmsgid}`;
|
|
1561
|
+
if ('requestId' in data && data.requestId)
|
|
1562
|
+
return `req:${data.requestId}`;
|
|
1563
|
+
if ('msgid' in data && data.msgid !== undefined)
|
|
1564
|
+
return `msg:${data.msgid}`;
|
|
1565
|
+
// Incoming: resolve the outgoing request by its echoed smsgid, but not if it opened a dialog
|
|
1566
|
+
if ('smsgid' in data && typeof data.smsgid === 'number') {
|
|
1567
|
+
const hasDialog = 'serviceApis' in data && Array.isArray(data.serviceApis) && checkForDialogInResponse(data.serviceApis);
|
|
1568
|
+
if (hasDialog)
|
|
1569
|
+
return null;
|
|
1570
|
+
const targetId = `cmsg:${data.smsgid}`;
|
|
1571
|
+
if (state.pendingRequests.has(targetId))
|
|
1572
|
+
return targetId;
|
|
1573
|
+
}
|
|
1574
|
+
return null;
|
|
1575
|
+
}
|
|
1576
|
+
/** Creates a serialisable snapshot of the current state for the Cypress consoleProps log. */
|
|
1577
|
+
function getStateSnapshot(state) {
|
|
1578
|
+
return {
|
|
1579
|
+
pendingRequests: Array.from(state.pendingRequests.keys()),
|
|
1580
|
+
loadingIndicator: state.loadingIndicator,
|
|
1581
|
+
heldRequests: Array.from(state.heldRequests),
|
|
1582
|
+
pendingDataSync: Array.from(state.pendingDataSync.keys()),
|
|
1583
|
+
lastProcessedIndex: state.lastProcessedIndex,
|
|
1584
|
+
lastOutgoingRequestId: state.lastOutgoingRequestId,
|
|
1585
|
+
windowDestroyDetected: state.windowDestroyDetected,
|
|
1586
|
+
windowDestroyTimestamp: state.windowDestroyTimestamp,
|
|
1587
|
+
extendedQuietPeriod: state.extendedQuietPeriod,
|
|
1588
|
+
pendingCellClickRequests: Array.from(state.pendingCellClickRequests.keys()),
|
|
1589
|
+
lastDialogCreatedTimestamp: state.lastDialogCreatedTimestamp,
|
|
1590
|
+
pendingDialogs: Array.from(pendingDialogs.entries())
|
|
1591
|
+
};
|
|
1592
|
+
}
|
|
1593
|
+
});
|