@cognior/iap-sdk 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of @cognior/iap-sdk might be problematic. Click here for more details.

Files changed (60) hide show
  1. package/.github/copilot-instructions.md +95 -0
  2. package/README.md +79 -0
  3. package/TRACKING.md +105 -0
  4. package/USER_CONTEXT_README.md +284 -0
  5. package/package.json +154 -0
  6. package/src/config.ts +25 -0
  7. package/src/core/flowEngine.ts +1833 -0
  8. package/src/core/triggerManager.ts +1011 -0
  9. package/src/experiences/banner.ts +366 -0
  10. package/src/experiences/beacon.ts +668 -0
  11. package/src/experiences/hotspotTour.ts +654 -0
  12. package/src/experiences/hotspots.ts +566 -0
  13. package/src/experiences/modal.ts +1337 -0
  14. package/src/experiences/modalSequence.ts +1247 -0
  15. package/src/experiences/popover.ts +652 -0
  16. package/src/experiences/registry.ts +21 -0
  17. package/src/experiences/survey.ts +1639 -0
  18. package/src/experiences/taskList.ts +625 -0
  19. package/src/experiences/tooltip.ts +740 -0
  20. package/src/experiences/types.ts +395 -0
  21. package/src/experiences/walkthrough.ts +670 -0
  22. package/src/flow-sequence.ts +177 -0
  23. package/src/flows.ts +512 -0
  24. package/src/http.ts +61 -0
  25. package/src/index.ts +355 -0
  26. package/src/services/flowManager.ts +905 -0
  27. package/src/services/flowNormalizer.ts +74 -0
  28. package/src/services/locationContextService.ts +189 -0
  29. package/src/services/pageContextService.ts +221 -0
  30. package/src/services/userContextService.ts +286 -0
  31. package/src/state/appState.ts +0 -0
  32. package/src/state/hooks.ts +0 -0
  33. package/src/state/index.ts +0 -0
  34. package/src/state/migration.ts +0 -0
  35. package/src/state/store.ts +0 -0
  36. package/src/styles/banner.css.ts +0 -0
  37. package/src/styles/hotspot.css.ts +0 -0
  38. package/src/styles/hotspotTour.css.ts +0 -0
  39. package/src/styles/modal.css.ts +564 -0
  40. package/src/styles/survey.css.ts +1013 -0
  41. package/src/styles/taskList.css.ts +0 -0
  42. package/src/styles/tooltip.css.ts +149 -0
  43. package/src/styles/walkthrough.css.ts +0 -0
  44. package/src/tourUtils.ts +0 -0
  45. package/src/tracking.ts +223 -0
  46. package/src/utils/debounce.ts +66 -0
  47. package/src/utils/eventSequenceValidator.ts +124 -0
  48. package/src/utils/flowTrackingSystem.ts +524 -0
  49. package/src/utils/idGenerator.ts +155 -0
  50. package/src/utils/immediateValidationPrevention.ts +184 -0
  51. package/src/utils/normalize.ts +50 -0
  52. package/src/utils/privacyManager.ts +166 -0
  53. package/src/utils/ruleEvaluator.ts +199 -0
  54. package/src/utils/sanitize.ts +79 -0
  55. package/src/utils/selectors.ts +107 -0
  56. package/src/utils/stepExecutor.ts +345 -0
  57. package/src/utils/triggerNormalizer.ts +149 -0
  58. package/src/utils/validationInterceptor.ts +650 -0
  59. package/tsconfig.json +13 -0
  60. package/tsup.config.ts +13 -0
@@ -0,0 +1,1011 @@
1
+ // src/core/triggerManager.ts
2
+ // Enhanced trigger system for step-level triggers with fallback support
3
+
4
+ import type {
5
+ TriggerDefinition,
6
+ TriggerCondition,
7
+ TriggerEvaluationContext,
8
+ TriggerEvaluationResult,
9
+ TriggerConditionKind,
10
+ TriggerType,
11
+ LogicalOperator,
12
+ RuleOperator
13
+ } from '../experiences/types';
14
+ import { resolveSelector, waitForElement } from '../utils/selectors';
15
+ import { pageContextService, type PageChangeEvent } from '../services/pageContextService';
16
+
17
+ /**
18
+ * Resolve selector to multiple elements (handles both CSS and XPath)
19
+ */
20
+ function resolveSelectorAll(selector: string, root: Document | Element = document): Element[] {
21
+ if (!selector || typeof selector !== 'string') return [];
22
+
23
+ // Try CSS
24
+ try {
25
+ const cssElements = (root as Document | Element).querySelectorAll(selector);
26
+ if (cssElements.length > 0) return Array.from(cssElements);
27
+ } catch {
28
+ // ignore invalid CSS: we'll try XPath next
29
+ }
30
+
31
+ // Try XPath
32
+ try {
33
+ const doc = root instanceof Document ? root : root.ownerDocument ?? document;
34
+ const result = doc.evaluate(
35
+ selector,
36
+ root as Node,
37
+ null,
38
+ XPathResult.ORDERED_NODE_SNAPSHOT_TYPE,
39
+ null
40
+ );
41
+
42
+ const elements: Element[] = [];
43
+ for (let i = 0; i < result.snapshotLength; i++) {
44
+ const element = result.snapshotItem(i) as Element;
45
+ if (element) elements.push(element);
46
+ }
47
+ return elements;
48
+ } catch {
49
+ return [];
50
+ }
51
+ }
52
+
53
+ /**
54
+ * Enhanced step interface with trigger support
55
+ */
56
+ export interface EnhancedStepData {
57
+ stepId: string;
58
+ stepOrder: number;
59
+ stepType: string; // "Mandatory" | "Optional"
60
+ trigger?: TriggerDefinition;
61
+ uxExperience?: {
62
+ uxExperienceType: string;
63
+ elementSelector?: string;
64
+ elementTrigger?: string;
65
+ elementLocation?: string;
66
+ position?: { x: string; y: string };
67
+ content: any;
68
+ modalContent?: any;
69
+ };
70
+ conditionRuleBlocks?: any[];
71
+ userInputSelector?: string | null;
72
+ }
73
+
74
+ /**
75
+ * Trigger Manager handles step-level trigger resolution and evaluation
76
+ * with page-aware context management for SPA and multi-page applications
77
+ */
78
+ export class TriggerManager {
79
+ private static _instance: TriggerManager;
80
+ private _activeListeners: Map<string, (() => void)[]> = new Map();
81
+ private _triggeredOnceSet: Set<string> = new Set();
82
+ private _pageContextUnsubscribe: (() => void) | null = null;
83
+ private _initialized = false;
84
+ private _registeredTriggers: Record<string, {
85
+ trigger: TriggerDefinition;
86
+ onTrigger: (context: TriggerEvaluationContext) => void;
87
+ flowContext?: { mode: 'Linear' | 'AnyOrder', currentStepActive: boolean };
88
+ }> = {};
89
+ private _waitTimeouts: Map<string, number> = new Map();
90
+ private _selectorWaitTimeout: number = 30000; // 30 seconds default
91
+
92
+ private constructor() {}
93
+
94
+ public static getInstance(): TriggerManager {
95
+ if (!this._instance) {
96
+ this._instance = new TriggerManager();
97
+ }
98
+ return this._instance;
99
+ }
100
+
101
+ /**
102
+ * Initialize trigger manager with page context tracking
103
+ */
104
+ public initialize(): void {
105
+ if (this._initialized) return;
106
+
107
+ // Initialize page context service
108
+ pageContextService.initialize();
109
+
110
+ // Subscribe to page changes
111
+ this._pageContextUnsubscribe = pageContextService.subscribe(this.handlePageChange.bind(this));
112
+
113
+ this._initialized = true;
114
+ }
115
+
116
+ /**
117
+ * Handle page change events - re-evaluate all registered triggers
118
+ */
119
+ private handlePageChange(event: PageChangeEvent): void {
120
+ console.debug(`[DAP] 📄 Page changed from ${event.previous?.pathname} to ${event.current.pathname}`);
121
+ console.debug('[DAP] Re-evaluating all registered triggers for new page context');
122
+
123
+ // Clear existing timeouts as page context changed
124
+ this.clearAllTimeouts();
125
+
126
+ // Clear triggered-once set on page changes for lifecycle triggers
127
+ if (event.type !== 'initial') {
128
+ this.clearLifecycleTriggers();
129
+ }
130
+
131
+ // Re-evaluate all registered triggers for new page
132
+ for (const [stepId, registration] of Object.entries(this._registeredTriggers)) {
133
+ console.debug(`[DAP] Re-registering triggers for step ${stepId} on new page`);
134
+
135
+ // Remove old listeners first
136
+ this.removeTriggerListeners(stepId);
137
+
138
+ // Re-register with new page context
139
+ this.registerTriggerListeners(
140
+ stepId,
141
+ registration.trigger,
142
+ registration.onTrigger,
143
+ registration.flowContext
144
+ );
145
+ }
146
+ }
147
+
148
+ /**
149
+ * Clear triggered-once set for lifecycle triggers on page change
150
+ */
151
+ private clearLifecycleTriggers(): void {
152
+ const lifecycleKeys = Array.from(this._triggeredOnceSet).filter(key =>
153
+ key.includes(':Lifecycle:')
154
+ );
155
+
156
+ lifecycleKeys.forEach(key => {
157
+ this._triggeredOnceSet.delete(key);
158
+ });
159
+ }
160
+
161
+ /**
162
+ * Re-evaluate active triggers on page change
163
+ */
164
+ private reEvaluateActiveTriggers(event: PageChangeEvent): void {
165
+ // Re-check all registered steps after page change
166
+ Object.entries(this._registeredTriggers).forEach(([stepId, { trigger, onTrigger, flowContext }]) => {
167
+ this.registerTriggerListeners(stepId, trigger, onTrigger, flowContext);
168
+ });
169
+ }
170
+
171
+ /**
172
+ * Re-register all triggers for page context changes
173
+ */
174
+ public reRegisterAllTriggers(): void {
175
+ Object.entries(this._registeredTriggers).forEach(([stepId, { trigger, onTrigger, flowContext }]) => {
176
+ this.registerTriggerListeners(stepId, trigger, onTrigger, flowContext);
177
+ });
178
+ }
179
+
180
+ /**
181
+ * Clean up resources
182
+ */
183
+ public destroy(): void {
184
+ if (!this._initialized) return;
185
+
186
+ // Clean up all active listeners
187
+ for (const stepId of this._activeListeners.keys()) {
188
+ this.removeTriggerListeners(stepId);
189
+ }
190
+
191
+ // Unsubscribe from page changes
192
+ if (this._pageContextUnsubscribe) {
193
+ this._pageContextUnsubscribe();
194
+ this._pageContextUnsubscribe = null;
195
+ }
196
+
197
+ this._activeListeners.clear();
198
+ this._triggeredOnceSet.clear();
199
+ this._registeredTriggers = {};
200
+ this._initialized = false;
201
+ }
202
+
203
+ /**
204
+ * Resolve trigger for a step following the priority rules:
205
+ * 1. Use step.trigger if it exists and has valid conditions
206
+ * 2. Fallback to uxExperience.elementTrigger if available
207
+ * 3. Return null if no trigger is resolvable
208
+ */
209
+ public resolveTrigger(step: EnhancedStepData): TriggerDefinition | null {
210
+ console.debug(`[DAP] Resolving trigger for step: ${step.stepId}`);
211
+
212
+ // Rule 1: Check if step has trigger with valid conditions
213
+ if (step.trigger && step.trigger.conditions && step.trigger.conditions.length > 0) {
214
+ console.log(`✅ [DAP] Step ${step.stepId}: Using STEP-LEVEL trigger with ${step.trigger.conditions.length} conditions`);
215
+ console.log(` └── Trigger type: ${step.trigger.type}, Event: ${step.trigger.conditions[0]?.event}, Kind: ${step.trigger.conditions[0]?.kind}`);
216
+
217
+ // Also log if elementTrigger exists (it will be ignored)
218
+ if (step.uxExperience?.elementTrigger) {
219
+ console.log(` ⚠️ Note: elementTrigger "${step.uxExperience.elementTrigger}" is present but IGNORED (step-level takes priority)`);
220
+ }
221
+
222
+ return step.trigger;
223
+ }
224
+
225
+ // Rule 2: Fallback to elementTrigger if available
226
+ if (step.uxExperience?.elementTrigger) {
227
+ console.warn(`⚠️ [DAP] Step ${step.stepId}: Falling back to ELEMENT-LEVEL trigger: "${step.uxExperience.elementTrigger}"`);
228
+ console.warn(` └── This fallback will be removed in the future! Please add step-level trigger.`);
229
+ console.warn(` └── Element selector: ${step.uxExperience.elementSelector}`);
230
+
231
+ const fallbackTrigger = this.convertElementTriggerToTriggerDefinition(
232
+ step.uxExperience.elementTrigger,
233
+ step.uxExperience.elementSelector
234
+ );
235
+ return fallbackTrigger;
236
+ }
237
+
238
+ // Rule 3: No trigger available
239
+ console.error(`❌ [DAP] Step ${step.stepId}: NO TRIGGER FOUND! Step will execute immediately.`);
240
+ console.error(` └── Consider adding either step-level trigger or elementTrigger`);
241
+ return null;
242
+ }
243
+
244
+ /**
245
+ * Convert legacy elementTrigger to TriggerDefinition
246
+ */
247
+ private convertElementTriggerToTriggerDefinition(
248
+ elementTrigger: string,
249
+ elementSelector?: string
250
+ ): TriggerDefinition {
251
+ let condition: TriggerCondition;
252
+
253
+ // Map legacy elementTrigger values
254
+ switch (elementTrigger.toLowerCase().trim()) {
255
+ case "on click":
256
+ case "click":
257
+ condition = {
258
+ kind: "Dom",
259
+ event: "click",
260
+ selector: elementSelector
261
+ };
262
+ break;
263
+
264
+ case "on hover":
265
+ case "hover":
266
+ condition = {
267
+ kind: "Dom",
268
+ event: "hover",
269
+ selector: elementSelector
270
+ };
271
+ break;
272
+
273
+ case "on page load":
274
+ case "page load":
275
+ condition = {
276
+ kind: "Lifecycle",
277
+ event: "page-load"
278
+ };
279
+ break;
280
+
281
+ case "on focus":
282
+ case "focus":
283
+ condition = {
284
+ kind: "Dom",
285
+ event: "focus",
286
+ selector: elementSelector
287
+ };
288
+ break;
289
+
290
+ default:
291
+ // Default to click if unrecognized
292
+ console.warn(`[DAP] Unknown elementTrigger: ${elementTrigger}, defaulting to click`);
293
+ condition = {
294
+ kind: "Dom",
295
+ event: "click",
296
+ selector: elementSelector
297
+ };
298
+ }
299
+
300
+ return {
301
+ type: "Single",
302
+ operator: "And",
303
+ once: true,
304
+ conditions: [condition]
305
+ };
306
+ }
307
+
308
+ /**
309
+ * Register trigger listeners for a step (page-aware)
310
+ */
311
+ public registerTriggerListeners(
312
+ stepId: string,
313
+ trigger: TriggerDefinition,
314
+ onTrigger: (context: TriggerEvaluationContext) => void,
315
+ flowContext?: { mode: 'Linear' | 'AnyOrder', currentStepActive: boolean }
316
+ ): void {
317
+ const currentPage = pageContextService.getPageId();
318
+
319
+ // Store trigger registration for re-evaluation on page changes
320
+ this._registeredTriggers[stepId] = { trigger, onTrigger, flowContext };
321
+
322
+ // Remove any existing listeners for this step
323
+ this.removeTriggerListeners(stepId);
324
+
325
+ const listeners: (() => void)[] = [];
326
+
327
+ for (const condition of trigger.conditions) {
328
+ const listener = this.createConditionListener(stepId, condition, trigger, onTrigger, flowContext);
329
+ if (listener) {
330
+ listeners.push(listener);
331
+ }
332
+ }
333
+
334
+ // Store listeners for cleanup
335
+ if (listeners.length > 0) {
336
+ this._activeListeners.set(stepId, listeners);
337
+ }
338
+ }
339
+
340
+ /**
341
+ * Create listener for individual trigger condition
342
+ */
343
+ private createConditionListener(
344
+ stepId: string,
345
+ condition: TriggerCondition,
346
+ trigger: TriggerDefinition,
347
+ onTrigger: (context: TriggerEvaluationContext) => void,
348
+ flowContext?: { mode: 'Linear' | 'AnyOrder', currentStepActive: boolean }
349
+ ): (() => void) | null {
350
+
351
+ switch (condition.kind) {
352
+ case "Dom":
353
+ return this.createDomListener(stepId, condition, trigger, onTrigger);
354
+
355
+ case "Lifecycle":
356
+ return this.createLifecycleListener(stepId, condition, trigger, onTrigger, flowContext);
357
+
358
+ case "Input":
359
+ return this.createInputListener(stepId, condition, trigger, onTrigger);
360
+
361
+ case "Time":
362
+ return this.createTimeListener(stepId, condition, trigger, onTrigger);
363
+
364
+ default:
365
+ console.warn(`[DAP] Unsupported condition kind: ${condition.kind}`);
366
+ return null;
367
+ }
368
+ }
369
+
370
+ /**
371
+ * Map trigger events to actual DOM events
372
+ */
373
+ private mapTriggerEventToDOMEvents(triggerEvent: string): string[] {
374
+ switch (triggerEvent) {
375
+ case 'hover':
376
+ return ['mouseenter'];
377
+ case 'click':
378
+ return ['click'];
379
+ case 'focus':
380
+ return ['focus'];
381
+ case 'blur':
382
+ return ['blur'];
383
+ case 'change':
384
+ return ['change'];
385
+ case 'input':
386
+ return ['input'];
387
+ case 'submit':
388
+ return ['submit'];
389
+ default:
390
+ // Return the event as-is for custom events
391
+ return [triggerEvent];
392
+ }
393
+ }
394
+
395
+ /**
396
+ * Create DOM event listener
397
+ */
398
+ private createDomListener(
399
+ stepId: string,
400
+ condition: TriggerCondition,
401
+ trigger: TriggerDefinition,
402
+ onTrigger: (context: TriggerEvaluationContext) => void
403
+ ): (() => void) | null {
404
+ if (!condition.selector) {
405
+ console.warn(`[DAP] DOM condition missing selector for step: ${stepId}`);
406
+ return null;
407
+ }
408
+
409
+ // Page context validation
410
+ const validation = this.validateSelectorOnCurrentPage(condition.selector);
411
+ console.debug(`[DAP] 📄 Page context validation for ${stepId}: selector exists=${validation.exists}, count=${validation.elementCount}`);
412
+
413
+ let targetElement: HTMLElement | null = null;
414
+ let observer: MutationObserver | null = null;
415
+ let timeoutCleanup: (() => void) | null = null;
416
+
417
+ // Function to attach listener to element
418
+ const attachListener = (element: HTMLElement) => {
419
+ // Clear timeout since we found the element
420
+ if (timeoutCleanup) {
421
+ timeoutCleanup();
422
+ timeoutCleanup = null;
423
+ }
424
+
425
+ // Map trigger event to appropriate DOM events
426
+ const eventNames = this.mapTriggerEventToDOMEvents(condition.event);
427
+ const cleanupFunctions: (() => void)[] = [];
428
+
429
+ console.debug(`[DAP] Mapping trigger event "${condition.event}" to DOM events:`, eventNames);
430
+
431
+ for (const eventName of eventNames) {
432
+ const eventHandler = (event: Event) => {
433
+ console.debug(`[DAP] DOM event triggered:`, event.type, condition.selector);
434
+
435
+ // Check if this is a "once" trigger and already fired
436
+ const onceKey = `${stepId}:${condition.kind}:${condition.event}`;
437
+ if (trigger.once && this._triggeredOnceSet.has(onceKey)) {
438
+ console.debug(`[DAP] Trigger already fired once for: ${onceKey}`);
439
+ return;
440
+ }
441
+
442
+ // Debouncing
443
+ if (condition.debounceMs) {
444
+ const debounceKey = `debounce:${stepId}:${condition.event}`;
445
+ const lastFired = this.getLastFiredTime(debounceKey);
446
+ const now = Date.now();
447
+ if (lastFired && (now - lastFired) < condition.debounceMs) {
448
+ console.debug(`[DAP] Event debounced for step: ${stepId}`);
449
+ return;
450
+ }
451
+ this.setLastFiredTime(debounceKey, now);
452
+ }
453
+
454
+ // Evaluate trigger
455
+ const context: TriggerEvaluationContext = {
456
+ stepId,
457
+ flowId: '', // Will be set by caller
458
+ element,
459
+ event
460
+ };
461
+
462
+ // For input events, capture the current input value
463
+ if (element && (element instanceof HTMLInputElement || element instanceof HTMLTextAreaElement || element instanceof HTMLSelectElement)) {
464
+ if (condition.event === 'input' || condition.event === 'change' || condition.event === 'keyup') {
465
+ context.userInput = element.value;
466
+ console.debug(`[DAP] Captured input value: "${context.userInput}" for step: ${stepId}`);
467
+ }
468
+ }
469
+
470
+ const result = this.evaluateTrigger(trigger, context);
471
+ if (result.triggered) {
472
+ if (trigger.once) {
473
+ this._triggeredOnceSet.add(onceKey);
474
+ }
475
+ onTrigger(context);
476
+ }
477
+ };
478
+
479
+ element.addEventListener(eventName, eventHandler);
480
+
481
+ cleanupFunctions.push(() => {
482
+ element.removeEventListener(eventName, eventHandler);
483
+ });
484
+ }
485
+
486
+ return () => {
487
+ cleanupFunctions.forEach(cleanup => cleanup());
488
+ };
489
+ };
490
+
491
+ // Try to find element immediately if validation passed
492
+ if (validation.exists) {
493
+ try {
494
+ targetElement = resolveSelector<HTMLElement>(condition.selector);
495
+ if (targetElement) {
496
+ console.debug(`[DAP] Element found immediately for selector: ${condition.selector}`);
497
+ return attachListener(targetElement);
498
+ }
499
+ } catch (error) {
500
+ console.warn(`[DAP] Invalid selector: ${condition.selector}`, error);
501
+ return null;
502
+ }
503
+ }
504
+
505
+ // Element not found, set up observer with timeout
506
+ console.debug(`[DAP] Element not found, waiting for: ${condition.selector}`);
507
+
508
+ let listenerCleanup: (() => void) | null = null;
509
+
510
+ // Set up timeout for element waiting
511
+ this.setupSelectorTimeout(stepId, condition.selector, () => {
512
+ console.warn(`[DAP] ⚠️ Selector timeout for step ${stepId}: ${condition.selector}`);
513
+
514
+ // Disconnect observer
515
+ if (observer) {
516
+ observer.disconnect();
517
+ observer = null;
518
+ }
519
+
520
+ // Emit telemetry event
521
+ console.warn(`[DAP] 📊 Telemetry: selector-not-found - Step: ${stepId}, Selector: ${condition.selector}`);
522
+
523
+ // TODO: Based on step type, either skip (Optional) or fail safely (Mandatory)
524
+ // For now, we just clean up and let the flow continue
525
+ });
526
+
527
+ // Keep reference to timeout cleanup
528
+ timeoutCleanup = () => this.clearTimeoutForStep(stepId);
529
+
530
+ observer = new MutationObserver(() => {
531
+ try {
532
+ const element = resolveSelector<HTMLElement>(condition.selector!);
533
+ if (element && element !== targetElement) {
534
+ targetElement = element;
535
+ console.debug(`[DAP] Element appeared: ${condition.selector}`);
536
+
537
+ // Cleanup observer and attach listener
538
+ if (observer) {
539
+ observer.disconnect();
540
+ }
541
+
542
+ listenerCleanup = attachListener(element);
543
+ }
544
+ } catch (error) {
545
+ // Ignore selector errors during observation
546
+ }
547
+ });
548
+
549
+ observer.observe(document.body, {
550
+ childList: true,
551
+ subtree: true
552
+ });
553
+
554
+ // Return cleanup function
555
+ return () => {
556
+ if (observer) {
557
+ observer.disconnect();
558
+ }
559
+ if (listenerCleanup) {
560
+ listenerCleanup();
561
+ }
562
+ };
563
+ }
564
+
565
+ /**
566
+ * Create lifecycle event listener (page-aware)
567
+ */
568
+ private createLifecycleListener(
569
+ stepId: string,
570
+ condition: TriggerCondition,
571
+ trigger: TriggerDefinition,
572
+ onTrigger: (context: TriggerEvaluationContext) => void,
573
+ flowContext?: { mode: 'Linear' | 'AnyOrder', currentStepActive: boolean }
574
+ ): (() => void) | null {
575
+ switch (condition.event) {
576
+ case "page-load":
577
+ // Check if this trigger should fire immediately
578
+ const onceKey = `${stepId}:${condition.kind}:${condition.event}`;
579
+ const shouldFireImmediately = !trigger.once || !this._triggeredOnceSet.has(onceKey);
580
+
581
+ if (shouldFireImmediately) {
582
+ // Fire immediately for current page
583
+ setTimeout(() => {
584
+ const context: TriggerEvaluationContext = {
585
+ stepId,
586
+ flowId: '', // Will be set by caller
587
+ pageState: {
588
+ loaded: true,
589
+ pageId: pageContextService.getPageId()
590
+ }
591
+ };
592
+
593
+ const result = this.evaluateTrigger(trigger, context);
594
+ if (result.triggered) {
595
+ if (trigger.once) {
596
+ this._triggeredOnceSet.add(onceKey);
597
+ }
598
+ onTrigger(context);
599
+ }
600
+ }, 100); // Small delay to ensure page is ready
601
+ }
602
+
603
+ // Also listen for future page changes
604
+ const pageChangeUnsubscribe = pageContextService.subscribe((event) => {
605
+ // Only fire on navigation events, not initial load
606
+ if (event.type === 'navigation' || event.type === 'reload') {
607
+ console.debug(`[DAP] Page change detected for page-load trigger, step: ${stepId}`);
608
+
609
+ const pageLoadOnceKey = `${stepId}:${condition.kind}:${condition.event}`;
610
+
611
+ // For page-load triggers, reset the "once" flag on page changes
612
+ if (trigger.once && event.type === 'navigation') {
613
+ this._triggeredOnceSet.delete(pageLoadOnceKey);
614
+ }
615
+
616
+ // Check if we should fire again
617
+ const shouldFire = !trigger.once || !this._triggeredOnceSet.has(pageLoadOnceKey);
618
+
619
+ if (shouldFire) {
620
+ const context: TriggerEvaluationContext = {
621
+ stepId,
622
+ flowId: '',
623
+ pageState: {
624
+ loaded: true,
625
+ pageId: pageContextService.getPageId(),
626
+ navigationEvent: event
627
+ }
628
+ };
629
+
630
+ const result = this.evaluateTrigger(trigger, context);
631
+ if (result.triggered) {
632
+ if (trigger.once) {
633
+ this._triggeredOnceSet.add(pageLoadOnceKey);
634
+ }
635
+ onTrigger(context);
636
+ }
637
+ }
638
+ }
639
+ });
640
+
641
+ return () => {
642
+ pageChangeUnsubscribe();
643
+ };
644
+
645
+ case "page-unload":
646
+ const unloadHandler = () => {
647
+ const context: TriggerEvaluationContext = {
648
+ stepId,
649
+ flowId: '',
650
+ pageState: {
651
+ unloading: true,
652
+ pageId: pageContextService.getPageId()
653
+ }
654
+ };
655
+
656
+ const result = this.evaluateTrigger(trigger, context);
657
+ if (result.triggered) {
658
+ onTrigger(context);
659
+ }
660
+ };
661
+
662
+ window.addEventListener('beforeunload', unloadHandler);
663
+ return () => {
664
+ window.removeEventListener('beforeunload', unloadHandler);
665
+ };
666
+
667
+ default:
668
+ console.warn(`[DAP] Unsupported lifecycle event: ${condition.event}`);
669
+ return null;
670
+ }
671
+ }
672
+
673
+ /**
674
+ * Create input event listener
675
+ */
676
+ private createInputListener(
677
+ stepId: string,
678
+ condition: TriggerCondition,
679
+ trigger: TriggerDefinition,
680
+ onTrigger: (context: TriggerEvaluationContext) => void
681
+ ): (() => void) | null {
682
+ if (!condition.selector) {
683
+ console.warn(`[DAP] Input condition missing selector for step: ${stepId}`);
684
+ return null;
685
+ }
686
+
687
+ // Page context validation for input selectors
688
+ const validation = this.validateSelectorOnCurrentPage(condition.selector);
689
+ console.debug(`[DAP] 📄 Input page context validation for ${stepId}: selector exists=${validation.exists}, count=${validation.elementCount}`);
690
+
691
+ const inputHandler = (event: Event) => {
692
+ const target = event.target as HTMLInputElement;
693
+ const value = target.value;
694
+
695
+ // Evaluate condition based on operator
696
+ let conditionMet = false;
697
+ if (condition.operator && condition.value !== undefined) {
698
+ conditionMet = this.evaluateCondition(value, condition.operator, condition.value);
699
+ } else {
700
+ conditionMet = true; // No specific condition, just trigger on input
701
+ }
702
+
703
+ if (conditionMet) {
704
+ const context: TriggerEvaluationContext = {
705
+ stepId,
706
+ flowId: '',
707
+ element: target,
708
+ event,
709
+ userInput: value
710
+ };
711
+
712
+ const result = this.evaluateTrigger(trigger, context);
713
+ if (result.triggered) {
714
+ onTrigger(context);
715
+ }
716
+ }
717
+ };
718
+
719
+ const elements = resolveSelectorAll(condition.selector!);
720
+
721
+ if (elements.length === 0) {
722
+ console.log(`[DAP] Input elements not found, waiting: ${condition.selector}`);
723
+
724
+ // Set up timeout for input element waiting
725
+ this.setupSelectorTimeout(stepId, condition.selector, () => {
726
+ console.warn(`[DAP] ⚠️ Input selector timeout for step ${stepId}: ${condition.selector}`);
727
+ console.warn(`[DAP] 📊 Telemetry: input-selector-not-found - Step: ${stepId}, Selector: ${condition.selector}`);
728
+
729
+ // Input elements critical for rule-based steps - this might indicate cross-page issue
730
+ if (stepId.includes('rule') || stepId.includes('condition')) {
731
+ console.error(`[DAP] 🚨 Rule-based step ${stepId} cannot find input selector - possible cross-page navigation issue`);
732
+ }
733
+ });
734
+
735
+ // Set up a MutationObserver to wait for the elements to appear
736
+ const observer = new MutationObserver(() => {
737
+ const foundElements = resolveSelectorAll(condition.selector!);
738
+ if (foundElements.length > 0) {
739
+ console.log(`[DAP] Input elements appeared: ${condition.selector}`);
740
+
741
+ // Clear timeout since elements found
742
+ this.clearTimeoutForStep(stepId);
743
+
744
+ foundElements.forEach(element => {
745
+ element.addEventListener('input', inputHandler);
746
+ element.addEventListener('change', inputHandler);
747
+ });
748
+ observer.disconnect();
749
+ }
750
+ });
751
+
752
+ observer.observe(document.body, {
753
+ childList: true,
754
+ subtree: true
755
+ });
756
+
757
+ // Return cleanup function that disconnects the observer and clears timeout
758
+ return () => {
759
+ this.clearTimeoutForStep(stepId);
760
+ observer.disconnect();
761
+ const foundElements = resolveSelectorAll(condition.selector!);
762
+ foundElements.forEach(element => {
763
+ element.removeEventListener('input', inputHandler);
764
+ element.removeEventListener('change', inputHandler);
765
+ });
766
+ };
767
+ }
768
+
769
+ // Elements found immediately
770
+ console.debug(`[DAP] ✅ Input elements found immediately: ${elements.length} element(s)`);
771
+ elements.forEach(element => {
772
+ element.addEventListener('input', inputHandler);
773
+ element.addEventListener('change', inputHandler);
774
+ });
775
+
776
+ return () => {
777
+ elements.forEach(element => {
778
+ element.removeEventListener('input', inputHandler);
779
+ element.removeEventListener('change', inputHandler);
780
+ });
781
+ };
782
+ }
783
+
784
+ /**
785
+ * Create time-based listener
786
+ */
787
+ private createTimeListener(
788
+ stepId: string,
789
+ condition: TriggerCondition,
790
+ trigger: TriggerDefinition,
791
+ onTrigger: (context: TriggerEvaluationContext) => void
792
+ ): (() => void) | null {
793
+ const delay = typeof condition.value === 'number' ? condition.value : 1000;
794
+
795
+ const timeoutId = setTimeout(() => {
796
+ const context: TriggerEvaluationContext = {
797
+ stepId,
798
+ flowId: '',
799
+ pageState: { timeElapsed: delay }
800
+ };
801
+
802
+ const result = this.evaluateTrigger(trigger, context);
803
+ if (result.triggered) {
804
+ onTrigger(context);
805
+ }
806
+ }, delay);
807
+
808
+ return () => {
809
+ clearTimeout(timeoutId);
810
+ };
811
+ }
812
+
813
+ /**
814
+ * Evaluate complete trigger (all conditions with logical operator)
815
+ */
816
+ public evaluateTrigger(
817
+ trigger: TriggerDefinition,
818
+ context: TriggerEvaluationContext
819
+ ): TriggerEvaluationResult {
820
+ const startTime = Date.now();
821
+ let matchedConditions = 0;
822
+ const totalConditions = trigger.conditions.length;
823
+
824
+ // For single condition triggers, just check if we have one condition
825
+ if (trigger.type === "Single" && totalConditions === 1) {
826
+ matchedConditions = 1; // Assume the condition was met if we're evaluating
827
+ } else if (trigger.type === "Composite") {
828
+ // For composite triggers, we need more sophisticated evaluation
829
+ // For now, we'll assume if we got here, at least one condition was met
830
+ matchedConditions = 1;
831
+ }
832
+
833
+ let triggered = false;
834
+ if (trigger.operator === "And") {
835
+ triggered = matchedConditions === totalConditions;
836
+ } else if (trigger.operator === "Or") {
837
+ triggered = matchedConditions > 0;
838
+ }
839
+
840
+ const result: TriggerEvaluationResult = {
841
+ triggered,
842
+ matchedConditions,
843
+ totalConditions,
844
+ evaluationTime: Date.now() - startTime,
845
+ debugInfo: {
846
+ triggerType: trigger.type,
847
+ operator: trigger.operator,
848
+ once: trigger.once
849
+ }
850
+ };
851
+
852
+ console.debug(`[DAP] Trigger evaluation result:`, result);
853
+ return result;
854
+ }
855
+
856
+ /**
857
+ * Evaluate individual condition value
858
+ */
859
+ private evaluateCondition(actualValue: any, operator: RuleOperator, expectedValue: any): boolean {
860
+ switch (operator) {
861
+ case "Equals":
862
+ return actualValue === expectedValue;
863
+ case "NotEquals":
864
+ return actualValue !== expectedValue;
865
+ case "Contains":
866
+ return String(actualValue).includes(String(expectedValue));
867
+ case "NotContains":
868
+ return !String(actualValue).includes(String(expectedValue));
869
+ case "StartsWith":
870
+ return String(actualValue).startsWith(String(expectedValue));
871
+ case "EndsWith":
872
+ return String(actualValue).endsWith(String(expectedValue));
873
+ case "GreaterThan":
874
+ return Number(actualValue) > Number(expectedValue);
875
+ case "LessThan":
876
+ return Number(actualValue) < Number(expectedValue);
877
+ case "GreaterThanOrEqual":
878
+ return Number(actualValue) >= Number(expectedValue);
879
+ case "LessThanOrEqual":
880
+ return Number(actualValue) <= Number(expectedValue);
881
+ case "Empty":
882
+ return !actualValue || String(actualValue).trim() === '';
883
+ case "In":
884
+ if (Array.isArray(expectedValue)) {
885
+ return expectedValue.includes(actualValue);
886
+ }
887
+ return false;
888
+ case "NotIn":
889
+ if (Array.isArray(expectedValue)) {
890
+ return !expectedValue.includes(actualValue);
891
+ }
892
+ return true;
893
+ case "Regex":
894
+ try {
895
+ const regex = new RegExp(String(expectedValue));
896
+ return regex.test(String(actualValue));
897
+ } catch {
898
+ return false;
899
+ }
900
+ default:
901
+ console.warn(`[DAP] Unsupported operator: ${operator}`);
902
+ return false;
903
+ }
904
+ }
905
+
906
+ /**
907
+ * Remove all listeners for a step
908
+ */
909
+ public removeTriggerListeners(stepId: string): void {
910
+ const listeners = this._activeListeners.get(stepId);
911
+ if (listeners) {
912
+ listeners.forEach(cleanup => cleanup());
913
+ this._activeListeners.delete(stepId);
914
+ }
915
+ // Note: Don't remove from _registeredTriggers as we need it for re-registration
916
+ }
917
+
918
+ /**
919
+ * Clear all listeners
920
+ */
921
+ public clearAllListeners(): void {
922
+ for (const [stepId, listeners] of this._activeListeners) {
923
+ listeners.forEach(cleanup => cleanup());
924
+ }
925
+ this._activeListeners.clear();
926
+ this._triggeredOnceSet.clear();
927
+ this._registeredTriggers = {};
928
+ this.clearAllTimeouts();
929
+ }
930
+
931
+ /**
932
+ * Clear all active timeouts
933
+ */
934
+ private clearAllTimeouts(): void {
935
+ for (const timeoutId of this._waitTimeouts.values()) {
936
+ clearTimeout(timeoutId);
937
+ }
938
+ this._waitTimeouts.clear();
939
+ }
940
+
941
+ /**
942
+ * Clear specific timeout for a step
943
+ */
944
+ private clearTimeoutForStep(stepId: string): void {
945
+ const timeoutId = this._waitTimeouts.get(stepId);
946
+ if (timeoutId) {
947
+ clearTimeout(timeoutId);
948
+ this._waitTimeouts.delete(stepId);
949
+ }
950
+ }
951
+
952
+ /**
953
+ * Validate if selector exists on current page
954
+ */
955
+ private validateSelectorOnCurrentPage(selector: string): {
956
+ exists: boolean;
957
+ elementCount: number;
958
+ validationTimestamp: number;
959
+ } {
960
+ const elements = resolveSelectorAll(selector);
961
+ return {
962
+ exists: elements.length > 0,
963
+ elementCount: elements.length,
964
+ validationTimestamp: Date.now()
965
+ };
966
+ }
967
+
968
+ /**
969
+ * Set up timeout for selector waiting with proper cleanup
970
+ */
971
+ private setupSelectorTimeout(
972
+ stepId: string,
973
+ selector: string,
974
+ onTimeout: () => void,
975
+ timeoutMs: number = this._selectorWaitTimeout
976
+ ): void {
977
+ // Clear any existing timeout
978
+ this.clearTimeoutForStep(stepId);
979
+
980
+ const timeoutId = setTimeout(() => {
981
+ console.warn(`[DAP] ⏰ Timeout: Selector not found within ${timeoutMs}ms for step ${stepId}: ${selector}`);
982
+ console.warn(`[DAP] Triggering timeout handler and cleaning up for step: ${stepId}`);
983
+ this._waitTimeouts.delete(stepId);
984
+ onTimeout();
985
+ }, timeoutMs);
986
+
987
+ this._waitTimeouts.set(stepId, timeoutId);
988
+ console.debug(`[DAP] ⏱️ Set ${timeoutMs}ms timeout for selector waiting: ${stepId}`);
989
+ }
990
+
991
+ /**
992
+ * Reset once-fired triggers for a new flow
993
+ */
994
+ public resetOnceTriggersForFlow(flowId: string): void {
995
+ // Clear all once-fired triggers (we could be more selective in the future)
996
+ this._triggeredOnceSet.clear();
997
+ }
998
+
999
+ // Helper methods for debouncing
1000
+ private _debounceTimestamps: Map<string, number> = new Map();
1001
+
1002
+ private getLastFiredTime(key: string): number | undefined {
1003
+ return this._debounceTimestamps.get(key);
1004
+ }
1005
+
1006
+ private setLastFiredTime(key: string, timestamp: number): void {
1007
+ this._debounceTimestamps.set(key, timestamp);
1008
+ }
1009
+ }
1010
+
1011
+ export const triggerManager = TriggerManager.getInstance();