@nextsparkjs/plugin-walkme 0.1.0-beta.104
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/.env.example +23 -0
- package/LICENSE +21 -0
- package/README.md +625 -0
- package/components/WalkmeBeacon.tsx +64 -0
- package/components/WalkmeControls.tsx +111 -0
- package/components/WalkmeModal.tsx +144 -0
- package/components/WalkmeOverlay.tsx +107 -0
- package/components/WalkmeProgress.tsx +53 -0
- package/components/WalkmeProvider.tsx +674 -0
- package/components/WalkmeSpotlight.tsx +188 -0
- package/components/WalkmeTooltip.tsx +152 -0
- package/examples/basic-tour.ts +38 -0
- package/examples/conditional-tour.ts +56 -0
- package/examples/cross-window-tour.ts +54 -0
- package/hooks/useTour.ts +52 -0
- package/hooks/useTourProgress.ts +38 -0
- package/hooks/useTourState.ts +146 -0
- package/hooks/useWalkme.ts +52 -0
- package/jest.config.cjs +27 -0
- package/lib/conditions.ts +113 -0
- package/lib/core.ts +323 -0
- package/lib/plugin-env.ts +87 -0
- package/lib/positioning.ts +172 -0
- package/lib/storage.ts +203 -0
- package/lib/targeting.ts +186 -0
- package/lib/triggers.ts +127 -0
- package/lib/validation.ts +122 -0
- package/messages/en.json +21 -0
- package/messages/es.json +21 -0
- package/package.json +18 -0
- package/plugin.config.ts +26 -0
- package/providers/walkme-context.ts +17 -0
- package/tests/lib/conditions.test.ts +172 -0
- package/tests/lib/core.test.ts +514 -0
- package/tests/lib/positioning.test.ts +43 -0
- package/tests/lib/storage.test.ts +232 -0
- package/tests/lib/targeting.test.ts +191 -0
- package/tests/lib/triggers.test.ts +198 -0
- package/tests/lib/validation.test.ts +249 -0
- package/tests/setup.ts +52 -0
- package/tests/tsconfig.json +32 -0
- package/tsconfig.json +47 -0
- package/types/walkme.types.ts +316 -0
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
import {
|
|
2
|
+
isStorageAvailable,
|
|
3
|
+
migrateStorage,
|
|
4
|
+
createStorageAdapter,
|
|
5
|
+
} from '../../lib/storage'
|
|
6
|
+
|
|
7
|
+
// ---------------------------------------------------------------------------
|
|
8
|
+
// isStorageAvailable
|
|
9
|
+
// ---------------------------------------------------------------------------
|
|
10
|
+
|
|
11
|
+
describe('isStorageAvailable', () => {
|
|
12
|
+
it('returns true when localStorage is available', () => {
|
|
13
|
+
expect(isStorageAvailable()).toBe(true)
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
it('returns false when localStorage throws', () => {
|
|
17
|
+
const original = window.localStorage.setItem
|
|
18
|
+
;(window.localStorage.setItem as jest.Mock).mockImplementationOnce(() => {
|
|
19
|
+
throw new Error('Storage disabled')
|
|
20
|
+
})
|
|
21
|
+
expect(isStorageAvailable()).toBe(false)
|
|
22
|
+
window.localStorage.setItem = original
|
|
23
|
+
})
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
// ---------------------------------------------------------------------------
|
|
27
|
+
// migrateStorage
|
|
28
|
+
// ---------------------------------------------------------------------------
|
|
29
|
+
|
|
30
|
+
describe('migrateStorage', () => {
|
|
31
|
+
it('returns default schema for null input', () => {
|
|
32
|
+
const result = migrateStorage(null)
|
|
33
|
+
expect(result.version).toBe(1)
|
|
34
|
+
expect(result.completedTours).toEqual([])
|
|
35
|
+
expect(result.skippedTours).toEqual([])
|
|
36
|
+
expect(result.activeTour).toBeNull()
|
|
37
|
+
expect(result.tourHistory).toEqual({})
|
|
38
|
+
expect(result.visitCount).toBe(0)
|
|
39
|
+
expect(result.firstVisitDate).toBeTruthy()
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
it('returns default schema for non-object input', () => {
|
|
43
|
+
const result = migrateStorage('invalid')
|
|
44
|
+
expect(result.version).toBe(1)
|
|
45
|
+
expect(result.completedTours).toEqual([])
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
it('preserves valid data during migration', () => {
|
|
49
|
+
const result = migrateStorage({
|
|
50
|
+
completedTours: ['tour-a', 'tour-b'],
|
|
51
|
+
skippedTours: ['tour-c'],
|
|
52
|
+
visitCount: 5,
|
|
53
|
+
firstVisitDate: '2024-01-01T00:00:00.000Z',
|
|
54
|
+
})
|
|
55
|
+
expect(result.completedTours).toEqual(['tour-a', 'tour-b'])
|
|
56
|
+
expect(result.skippedTours).toEqual(['tour-c'])
|
|
57
|
+
expect(result.visitCount).toBe(5)
|
|
58
|
+
expect(result.firstVisitDate).toBe('2024-01-01T00:00:00.000Z')
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
it('handles missing fields gracefully', () => {
|
|
62
|
+
const result = migrateStorage({})
|
|
63
|
+
expect(result.completedTours).toEqual([])
|
|
64
|
+
expect(result.skippedTours).toEqual([])
|
|
65
|
+
expect(result.visitCount).toBe(0)
|
|
66
|
+
})
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
// ---------------------------------------------------------------------------
|
|
70
|
+
// createStorageAdapter
|
|
71
|
+
// ---------------------------------------------------------------------------
|
|
72
|
+
|
|
73
|
+
describe('createStorageAdapter', () => {
|
|
74
|
+
let adapter: ReturnType<typeof createStorageAdapter>
|
|
75
|
+
|
|
76
|
+
beforeEach(() => {
|
|
77
|
+
adapter = createStorageAdapter()
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
describe('load / save', () => {
|
|
81
|
+
it('returns null on first load (no data)', () => {
|
|
82
|
+
// localStorage starts empty in our mock, but load still creates a schema
|
|
83
|
+
// because the adapter calls read() which returns default
|
|
84
|
+
const data = adapter.load()
|
|
85
|
+
// load returns null if localStorage has nothing, but read() creates default
|
|
86
|
+
// Actually looking at the code: load returns read() which creates default if no raw data
|
|
87
|
+
expect(data).toBeDefined()
|
|
88
|
+
expect(data!.completedTours).toEqual([])
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
it('saves and loads state correctly', () => {
|
|
92
|
+
const schema = {
|
|
93
|
+
version: 1,
|
|
94
|
+
completedTours: ['tour-1'],
|
|
95
|
+
skippedTours: [],
|
|
96
|
+
activeTour: null,
|
|
97
|
+
tourHistory: {},
|
|
98
|
+
visitCount: 3,
|
|
99
|
+
firstVisitDate: '2024-01-01T00:00:00.000Z',
|
|
100
|
+
}
|
|
101
|
+
adapter.save(schema)
|
|
102
|
+
const loaded = adapter.load()
|
|
103
|
+
expect(loaded!.completedTours).toEqual(['tour-1'])
|
|
104
|
+
expect(loaded!.visitCount).toBe(3)
|
|
105
|
+
})
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
describe('reset', () => {
|
|
109
|
+
it('clears all stored data', () => {
|
|
110
|
+
adapter.save({
|
|
111
|
+
version: 1,
|
|
112
|
+
completedTours: ['tour-1'],
|
|
113
|
+
skippedTours: [],
|
|
114
|
+
activeTour: null,
|
|
115
|
+
tourHistory: {},
|
|
116
|
+
visitCount: 1,
|
|
117
|
+
firstVisitDate: '2024-01-01T00:00:00.000Z',
|
|
118
|
+
})
|
|
119
|
+
adapter.reset()
|
|
120
|
+
// After reset, load should return fresh default data
|
|
121
|
+
const loaded = adapter.load()
|
|
122
|
+
expect(loaded!.completedTours).toEqual([])
|
|
123
|
+
})
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
describe('resetTour', () => {
|
|
127
|
+
it('removes a specific tour from completed/skipped', () => {
|
|
128
|
+
adapter.save({
|
|
129
|
+
version: 1,
|
|
130
|
+
completedTours: ['tour-a', 'tour-b'],
|
|
131
|
+
skippedTours: ['tour-a'],
|
|
132
|
+
activeTour: null,
|
|
133
|
+
tourHistory: {
|
|
134
|
+
'tour-a': { tourId: 'tour-a', status: 'completed', currentStepIndex: 0, startedAt: '' },
|
|
135
|
+
},
|
|
136
|
+
visitCount: 1,
|
|
137
|
+
firstVisitDate: '2024-01-01T00:00:00.000Z',
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
adapter.resetTour('tour-a')
|
|
141
|
+
const loaded = adapter.load()
|
|
142
|
+
expect(loaded!.completedTours).toEqual(['tour-b'])
|
|
143
|
+
expect(loaded!.skippedTours).toEqual([])
|
|
144
|
+
expect(loaded!.tourHistory['tour-a']).toBeUndefined()
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
it('clears activeTour if it matches', () => {
|
|
148
|
+
adapter.save({
|
|
149
|
+
version: 1,
|
|
150
|
+
completedTours: [],
|
|
151
|
+
skippedTours: [],
|
|
152
|
+
activeTour: { tourId: 'tour-x', status: 'active', currentStepIndex: 0, startedAt: '' },
|
|
153
|
+
tourHistory: {},
|
|
154
|
+
visitCount: 1,
|
|
155
|
+
firstVisitDate: '2024-01-01T00:00:00.000Z',
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
adapter.resetTour('tour-x')
|
|
159
|
+
const loaded = adapter.load()
|
|
160
|
+
expect(loaded!.activeTour).toBeNull()
|
|
161
|
+
})
|
|
162
|
+
})
|
|
163
|
+
|
|
164
|
+
describe('getCompletedTours / getSkippedTours', () => {
|
|
165
|
+
it('returns empty arrays by default', () => {
|
|
166
|
+
expect(adapter.getCompletedTours()).toEqual([])
|
|
167
|
+
expect(adapter.getSkippedTours()).toEqual([])
|
|
168
|
+
})
|
|
169
|
+
|
|
170
|
+
it('returns stored values', () => {
|
|
171
|
+
adapter.save({
|
|
172
|
+
version: 1,
|
|
173
|
+
completedTours: ['a'],
|
|
174
|
+
skippedTours: ['b'],
|
|
175
|
+
activeTour: null,
|
|
176
|
+
tourHistory: {},
|
|
177
|
+
visitCount: 0,
|
|
178
|
+
firstVisitDate: '2024-01-01T00:00:00.000Z',
|
|
179
|
+
})
|
|
180
|
+
expect(adapter.getCompletedTours()).toEqual(['a'])
|
|
181
|
+
expect(adapter.getSkippedTours()).toEqual(['b'])
|
|
182
|
+
})
|
|
183
|
+
})
|
|
184
|
+
|
|
185
|
+
describe('visitCount', () => {
|
|
186
|
+
it('starts at 0', () => {
|
|
187
|
+
expect(adapter.getVisitCount()).toBe(0)
|
|
188
|
+
})
|
|
189
|
+
|
|
190
|
+
it('increments visit count', () => {
|
|
191
|
+
adapter.incrementVisitCount()
|
|
192
|
+
expect(adapter.getVisitCount()).toBe(1)
|
|
193
|
+
adapter.incrementVisitCount()
|
|
194
|
+
expect(adapter.getVisitCount()).toBe(2)
|
|
195
|
+
})
|
|
196
|
+
})
|
|
197
|
+
|
|
198
|
+
describe('firstVisitDate', () => {
|
|
199
|
+
it('returns a date string by default', () => {
|
|
200
|
+
// The default schema sets firstVisitDate to now
|
|
201
|
+
const date = adapter.getFirstVisitDate()
|
|
202
|
+
expect(date).toBeTruthy()
|
|
203
|
+
})
|
|
204
|
+
|
|
205
|
+
it('sets and gets first visit date', () => {
|
|
206
|
+
adapter.setFirstVisitDate('2025-06-01T00:00:00.000Z')
|
|
207
|
+
expect(adapter.getFirstVisitDate()).toBe('2025-06-01T00:00:00.000Z')
|
|
208
|
+
})
|
|
209
|
+
})
|
|
210
|
+
|
|
211
|
+
describe('activeTour', () => {
|
|
212
|
+
it('returns null by default', () => {
|
|
213
|
+
expect(adapter.getActiveTour()).toBeNull()
|
|
214
|
+
})
|
|
215
|
+
|
|
216
|
+
it('sets and gets active tour', () => {
|
|
217
|
+
const tour = { tourId: 'tour-1', status: 'active' as const, currentStepIndex: 2, startedAt: '2024-01-01T00:00:00.000Z' }
|
|
218
|
+
adapter.setActiveTour(tour)
|
|
219
|
+
const loaded = adapter.getActiveTour()
|
|
220
|
+
expect(loaded).not.toBeNull()
|
|
221
|
+
expect(loaded!.tourId).toBe('tour-1')
|
|
222
|
+
expect(loaded!.currentStepIndex).toBe(2)
|
|
223
|
+
})
|
|
224
|
+
|
|
225
|
+
it('clears active tour with null', () => {
|
|
226
|
+
const tour = { tourId: 'tour-1', status: 'active' as const, currentStepIndex: 0, startedAt: '' }
|
|
227
|
+
adapter.setActiveTour(tour)
|
|
228
|
+
adapter.setActiveTour(null)
|
|
229
|
+
expect(adapter.getActiveTour()).toBeNull()
|
|
230
|
+
})
|
|
231
|
+
})
|
|
232
|
+
})
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
import {
|
|
2
|
+
findTarget,
|
|
3
|
+
waitForTarget,
|
|
4
|
+
isElementVisible,
|
|
5
|
+
isElementInViewport,
|
|
6
|
+
scrollToElement,
|
|
7
|
+
getElementRect,
|
|
8
|
+
} from '../../lib/targeting'
|
|
9
|
+
|
|
10
|
+
// ---------------------------------------------------------------------------
|
|
11
|
+
// findTarget
|
|
12
|
+
// ---------------------------------------------------------------------------
|
|
13
|
+
|
|
14
|
+
describe('findTarget', () => {
|
|
15
|
+
afterEach(() => {
|
|
16
|
+
document.body.innerHTML = ''
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
it('finds element by data-walkme-target for plain name selectors', () => {
|
|
20
|
+
const el = document.createElement('div')
|
|
21
|
+
el.setAttribute('data-walkme-target', 'my-target')
|
|
22
|
+
document.body.appendChild(el)
|
|
23
|
+
|
|
24
|
+
const result = findTarget('my-target')
|
|
25
|
+
expect(result.found).toBe(true)
|
|
26
|
+
expect(result.element).toBe(el)
|
|
27
|
+
expect(result.method).toBe('data-walkme')
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
it('finds element by CSS selector', () => {
|
|
31
|
+
const el = document.createElement('div')
|
|
32
|
+
el.id = 'my-element'
|
|
33
|
+
document.body.appendChild(el)
|
|
34
|
+
|
|
35
|
+
const result = findTarget('#my-element')
|
|
36
|
+
expect(result.found).toBe(true)
|
|
37
|
+
expect(result.element).toBe(el)
|
|
38
|
+
expect(result.method).toBe('css')
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
it('finds element by data-cy attribute', () => {
|
|
42
|
+
const el = document.createElement('button')
|
|
43
|
+
el.setAttribute('data-cy', 'create-button')
|
|
44
|
+
document.body.appendChild(el)
|
|
45
|
+
|
|
46
|
+
const result = findTarget('[data-cy="create-button"]')
|
|
47
|
+
expect(result.found).toBe(true)
|
|
48
|
+
expect(result.element).toBe(el)
|
|
49
|
+
expect(result.method).toBe('data-cy')
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
it('returns not found for missing element', () => {
|
|
53
|
+
const result = findTarget('#nonexistent')
|
|
54
|
+
expect(result.found).toBe(false)
|
|
55
|
+
expect(result.element).toBeNull()
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
it('returns not found for invalid CSS selector', () => {
|
|
59
|
+
const result = findTarget('[[[invalid')
|
|
60
|
+
expect(result.found).toBe(false)
|
|
61
|
+
expect(result.element).toBeNull()
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
it('prefers data-walkme-target over CSS for plain names', () => {
|
|
65
|
+
const walkmeEl = document.createElement('div')
|
|
66
|
+
walkmeEl.setAttribute('data-walkme-target', 'sidebar')
|
|
67
|
+
document.body.appendChild(walkmeEl)
|
|
68
|
+
|
|
69
|
+
// Also create an element with id="sidebar"
|
|
70
|
+
const idEl = document.createElement('div')
|
|
71
|
+
idEl.id = 'sidebar'
|
|
72
|
+
document.body.appendChild(idEl)
|
|
73
|
+
|
|
74
|
+
const result = findTarget('sidebar')
|
|
75
|
+
expect(result.found).toBe(true)
|
|
76
|
+
expect(result.method).toBe('data-walkme')
|
|
77
|
+
expect(result.element).toBe(walkmeEl)
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
it('falls back to CSS when data-walkme-target not found', () => {
|
|
81
|
+
const el = document.createElement('div')
|
|
82
|
+
el.className = 'my-class'
|
|
83
|
+
document.body.appendChild(el)
|
|
84
|
+
|
|
85
|
+
const result = findTarget('.my-class')
|
|
86
|
+
expect(result.found).toBe(true)
|
|
87
|
+
expect(result.method).toBe('css')
|
|
88
|
+
})
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
// ---------------------------------------------------------------------------
|
|
92
|
+
// waitForTarget
|
|
93
|
+
// ---------------------------------------------------------------------------
|
|
94
|
+
|
|
95
|
+
describe('waitForTarget', () => {
|
|
96
|
+
afterEach(() => {
|
|
97
|
+
document.body.innerHTML = ''
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
it('resolves immediately if element exists', async () => {
|
|
101
|
+
const el = document.createElement('div')
|
|
102
|
+
el.id = 'existing'
|
|
103
|
+
document.body.appendChild(el)
|
|
104
|
+
|
|
105
|
+
const result = await waitForTarget('#existing')
|
|
106
|
+
expect(result.found).toBe(true)
|
|
107
|
+
expect(result.element).toBe(el)
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
it('resolves not found after timeout', async () => {
|
|
111
|
+
const result = await waitForTarget('#nonexistent', { timeout: 100, interval: 50 })
|
|
112
|
+
expect(result.found).toBe(false)
|
|
113
|
+
expect(result.element).toBeNull()
|
|
114
|
+
})
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
// ---------------------------------------------------------------------------
|
|
118
|
+
// isElementVisible
|
|
119
|
+
// ---------------------------------------------------------------------------
|
|
120
|
+
|
|
121
|
+
describe('isElementVisible', () => {
|
|
122
|
+
it('returns true for visible element', () => {
|
|
123
|
+
const el = document.createElement('div')
|
|
124
|
+
document.body.appendChild(el)
|
|
125
|
+
// jsdom defaults: display is '', visibility is '', opacity is ''
|
|
126
|
+
// getComputedStyle in jsdom returns empty strings, not 'none' or 'hidden'
|
|
127
|
+
// However, offsetParent is null for elements not in a rendered layout
|
|
128
|
+
// In jsdom, offsetParent is always null, so this will return false
|
|
129
|
+
const result = isElementVisible(el)
|
|
130
|
+
// jsdom limitation: offsetParent is always null
|
|
131
|
+
expect(typeof result).toBe('boolean')
|
|
132
|
+
})
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
// ---------------------------------------------------------------------------
|
|
136
|
+
// isElementInViewport
|
|
137
|
+
// ---------------------------------------------------------------------------
|
|
138
|
+
|
|
139
|
+
describe('isElementInViewport', () => {
|
|
140
|
+
it('checks viewport bounds', () => {
|
|
141
|
+
const el = document.createElement('div')
|
|
142
|
+
document.body.appendChild(el)
|
|
143
|
+
// getBoundingClientRect returns all zeros in jsdom
|
|
144
|
+
const result = isElementInViewport(el)
|
|
145
|
+
// In jsdom all rect values are 0, and 0 >= 0 is true, 0 <= innerHeight is true
|
|
146
|
+
expect(typeof result).toBe('boolean')
|
|
147
|
+
})
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
// ---------------------------------------------------------------------------
|
|
151
|
+
// scrollToElement
|
|
152
|
+
// ---------------------------------------------------------------------------
|
|
153
|
+
|
|
154
|
+
describe('scrollToElement', () => {
|
|
155
|
+
it('calls scrollIntoView on the element', () => {
|
|
156
|
+
const el = document.createElement('div')
|
|
157
|
+
document.body.appendChild(el)
|
|
158
|
+
scrollToElement(el)
|
|
159
|
+
expect(el.scrollIntoView).toHaveBeenCalledWith({
|
|
160
|
+
behavior: 'smooth',
|
|
161
|
+
block: 'center',
|
|
162
|
+
})
|
|
163
|
+
})
|
|
164
|
+
|
|
165
|
+
it('accepts custom options', () => {
|
|
166
|
+
const el = document.createElement('div')
|
|
167
|
+
document.body.appendChild(el)
|
|
168
|
+
scrollToElement(el, { behavior: 'instant', block: 'start' })
|
|
169
|
+
expect(el.scrollIntoView).toHaveBeenCalledWith({
|
|
170
|
+
behavior: 'instant',
|
|
171
|
+
block: 'start',
|
|
172
|
+
})
|
|
173
|
+
})
|
|
174
|
+
})
|
|
175
|
+
|
|
176
|
+
// ---------------------------------------------------------------------------
|
|
177
|
+
// getElementRect
|
|
178
|
+
// ---------------------------------------------------------------------------
|
|
179
|
+
|
|
180
|
+
describe('getElementRect', () => {
|
|
181
|
+
it('returns a rect object with expected properties', () => {
|
|
182
|
+
const el = document.createElement('div')
|
|
183
|
+
document.body.appendChild(el)
|
|
184
|
+
const rect = getElementRect(el)
|
|
185
|
+
expect(rect).toHaveProperty('top')
|
|
186
|
+
expect(rect).toHaveProperty('left')
|
|
187
|
+
expect(rect).toHaveProperty('width')
|
|
188
|
+
expect(rect).toHaveProperty('height')
|
|
189
|
+
expect(typeof rect.top).toBe('number')
|
|
190
|
+
})
|
|
191
|
+
})
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
import type { Tour } from '../../types/walkme.types'
|
|
2
|
+
import {
|
|
3
|
+
shouldTriggerTour,
|
|
4
|
+
evaluateOnFirstVisit,
|
|
5
|
+
evaluateOnRouteEnter,
|
|
6
|
+
evaluateOnEvent,
|
|
7
|
+
evaluateScheduled,
|
|
8
|
+
type TriggerEvaluationContext,
|
|
9
|
+
} from '../../lib/triggers'
|
|
10
|
+
|
|
11
|
+
// ---------------------------------------------------------------------------
|
|
12
|
+
// Fixtures
|
|
13
|
+
// ---------------------------------------------------------------------------
|
|
14
|
+
|
|
15
|
+
function createContext(overrides: Partial<TriggerEvaluationContext> = {}): TriggerEvaluationContext {
|
|
16
|
+
return {
|
|
17
|
+
currentRoute: '/dashboard',
|
|
18
|
+
visitCount: 1,
|
|
19
|
+
firstVisitDate: new Date().toISOString(),
|
|
20
|
+
completedTourIds: [],
|
|
21
|
+
customEvents: new Set(),
|
|
22
|
+
...overrides,
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function createTour(trigger: Tour['trigger']): Tour {
|
|
27
|
+
return {
|
|
28
|
+
id: 'test',
|
|
29
|
+
name: 'Test',
|
|
30
|
+
trigger,
|
|
31
|
+
steps: [{ id: 's1', type: 'modal', title: 'T', content: 'C', actions: ['next'] }],
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// ---------------------------------------------------------------------------
|
|
36
|
+
// shouldTriggerTour
|
|
37
|
+
// ---------------------------------------------------------------------------
|
|
38
|
+
|
|
39
|
+
describe('shouldTriggerTour', () => {
|
|
40
|
+
it('delegates to onFirstVisit evaluator', () => {
|
|
41
|
+
const tour = createTour({ type: 'onFirstVisit' })
|
|
42
|
+
expect(shouldTriggerTour(tour, createContext({ visitCount: 1 }))).toBe(true)
|
|
43
|
+
expect(shouldTriggerTour(tour, createContext({ visitCount: 2 }))).toBe(false)
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
it('returns false for manual trigger', () => {
|
|
47
|
+
const tour = createTour({ type: 'manual' })
|
|
48
|
+
expect(shouldTriggerTour(tour, createContext())).toBe(false)
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
it('returns false for unknown trigger type', () => {
|
|
52
|
+
const tour = createTour({ type: 'unknown' as any })
|
|
53
|
+
expect(shouldTriggerTour(tour, createContext())).toBe(false)
|
|
54
|
+
})
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
// ---------------------------------------------------------------------------
|
|
58
|
+
// evaluateOnFirstVisit
|
|
59
|
+
// ---------------------------------------------------------------------------
|
|
60
|
+
|
|
61
|
+
describe('evaluateOnFirstVisit', () => {
|
|
62
|
+
it('returns true when visitCount is 1', () => {
|
|
63
|
+
expect(evaluateOnFirstVisit({ type: 'onFirstVisit' }, createContext({ visitCount: 1 }))).toBe(true)
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
it('returns false when visitCount is > 1', () => {
|
|
67
|
+
expect(evaluateOnFirstVisit({ type: 'onFirstVisit' }, createContext({ visitCount: 5 }))).toBe(false)
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
it('returns false when visitCount is 0', () => {
|
|
71
|
+
expect(evaluateOnFirstVisit({ type: 'onFirstVisit' }, createContext({ visitCount: 0 }))).toBe(false)
|
|
72
|
+
})
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
// ---------------------------------------------------------------------------
|
|
76
|
+
// evaluateOnRouteEnter
|
|
77
|
+
// ---------------------------------------------------------------------------
|
|
78
|
+
|
|
79
|
+
describe('evaluateOnRouteEnter', () => {
|
|
80
|
+
it('matches exact route', () => {
|
|
81
|
+
const result = evaluateOnRouteEnter(
|
|
82
|
+
{ type: 'onRouteEnter', route: '/dashboard' },
|
|
83
|
+
createContext({ currentRoute: '/dashboard' }),
|
|
84
|
+
)
|
|
85
|
+
expect(result).toBe(true)
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
it('does not match different route', () => {
|
|
89
|
+
const result = evaluateOnRouteEnter(
|
|
90
|
+
{ type: 'onRouteEnter', route: '/settings' },
|
|
91
|
+
createContext({ currentRoute: '/dashboard' }),
|
|
92
|
+
)
|
|
93
|
+
expect(result).toBe(false)
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
it('matches wildcard pattern /admin/*', () => {
|
|
97
|
+
const trigger = { type: 'onRouteEnter' as const, route: '/admin/*' }
|
|
98
|
+
expect(evaluateOnRouteEnter(trigger, createContext({ currentRoute: '/admin/users' }))).toBe(true)
|
|
99
|
+
expect(evaluateOnRouteEnter(trigger, createContext({ currentRoute: '/admin' }))).toBe(true)
|
|
100
|
+
expect(evaluateOnRouteEnter(trigger, createContext({ currentRoute: '/other' }))).toBe(false)
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
it('matches glob pattern /docs/**', () => {
|
|
104
|
+
const trigger = { type: 'onRouteEnter' as const, route: '/docs/**' }
|
|
105
|
+
expect(evaluateOnRouteEnter(trigger, createContext({ currentRoute: '/docs/a/b/c' }))).toBe(true)
|
|
106
|
+
expect(evaluateOnRouteEnter(trigger, createContext({ currentRoute: '/docs' }))).toBe(true)
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
it('returns false when no route in trigger', () => {
|
|
110
|
+
const result = evaluateOnRouteEnter({ type: 'onRouteEnter' }, createContext())
|
|
111
|
+
expect(result).toBe(false)
|
|
112
|
+
})
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
// ---------------------------------------------------------------------------
|
|
116
|
+
// evaluateOnEvent
|
|
117
|
+
// ---------------------------------------------------------------------------
|
|
118
|
+
|
|
119
|
+
describe('evaluateOnEvent', () => {
|
|
120
|
+
it('returns true when event is in customEvents set', () => {
|
|
121
|
+
const events = new Set(['my-event'])
|
|
122
|
+
const result = evaluateOnEvent(
|
|
123
|
+
{ type: 'onEvent', event: 'my-event' },
|
|
124
|
+
createContext({ customEvents: events }),
|
|
125
|
+
)
|
|
126
|
+
expect(result).toBe(true)
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
it('returns false when event is not in set', () => {
|
|
130
|
+
const result = evaluateOnEvent(
|
|
131
|
+
{ type: 'onEvent', event: 'my-event' },
|
|
132
|
+
createContext({ customEvents: new Set() }),
|
|
133
|
+
)
|
|
134
|
+
expect(result).toBe(false)
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
it('returns false when no event in trigger', () => {
|
|
138
|
+
const result = evaluateOnEvent({ type: 'onEvent' }, createContext())
|
|
139
|
+
expect(result).toBe(false)
|
|
140
|
+
})
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
// ---------------------------------------------------------------------------
|
|
144
|
+
// evaluateScheduled
|
|
145
|
+
// ---------------------------------------------------------------------------
|
|
146
|
+
|
|
147
|
+
describe('evaluateScheduled', () => {
|
|
148
|
+
it('triggers when visitCount meets afterVisits threshold', () => {
|
|
149
|
+
const result = evaluateScheduled(
|
|
150
|
+
{ type: 'scheduled', afterVisits: 5 },
|
|
151
|
+
createContext({ visitCount: 5 }),
|
|
152
|
+
)
|
|
153
|
+
expect(result).toBe(true)
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
it('does not trigger when visitCount is below threshold', () => {
|
|
157
|
+
const result = evaluateScheduled(
|
|
158
|
+
{ type: 'scheduled', afterVisits: 5 },
|
|
159
|
+
createContext({ visitCount: 3 }),
|
|
160
|
+
)
|
|
161
|
+
expect(result).toBe(false)
|
|
162
|
+
})
|
|
163
|
+
|
|
164
|
+
it('triggers when enough days have passed since first visit', () => {
|
|
165
|
+
const tenDaysAgo = new Date()
|
|
166
|
+
tenDaysAgo.setDate(tenDaysAgo.getDate() - 10)
|
|
167
|
+
|
|
168
|
+
const result = evaluateScheduled(
|
|
169
|
+
{ type: 'scheduled', afterDays: 7 },
|
|
170
|
+
createContext({ firstVisitDate: tenDaysAgo.toISOString() }),
|
|
171
|
+
)
|
|
172
|
+
expect(result).toBe(true)
|
|
173
|
+
})
|
|
174
|
+
|
|
175
|
+
it('does not trigger when not enough days have passed', () => {
|
|
176
|
+
const twoDaysAgo = new Date()
|
|
177
|
+
twoDaysAgo.setDate(twoDaysAgo.getDate() - 2)
|
|
178
|
+
|
|
179
|
+
const result = evaluateScheduled(
|
|
180
|
+
{ type: 'scheduled', afterDays: 7 },
|
|
181
|
+
createContext({ firstVisitDate: twoDaysAgo.toISOString() }),
|
|
182
|
+
)
|
|
183
|
+
expect(result).toBe(false)
|
|
184
|
+
})
|
|
185
|
+
|
|
186
|
+
it('returns false when no thresholds specified', () => {
|
|
187
|
+
const result = evaluateScheduled({ type: 'scheduled' }, createContext())
|
|
188
|
+
expect(result).toBe(false)
|
|
189
|
+
})
|
|
190
|
+
|
|
191
|
+
it('returns false when firstVisitDate is null for afterDays', () => {
|
|
192
|
+
const result = evaluateScheduled(
|
|
193
|
+
{ type: 'scheduled', afterDays: 1 },
|
|
194
|
+
createContext({ firstVisitDate: null }),
|
|
195
|
+
)
|
|
196
|
+
expect(result).toBe(false)
|
|
197
|
+
})
|
|
198
|
+
})
|