odac 1.4.4 → 1.4.5

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.
@@ -0,0 +1,306 @@
1
+ describe('Odac.load()', () => {
2
+ let mockXhr, mockDocument, mockWindow
3
+
4
+ const setupMocks = (options = {}) => {
5
+ jest.resetModules()
6
+ mockXhr = {
7
+ open: jest.fn(),
8
+ setRequestHeader: jest.fn(),
9
+ send: jest.fn(),
10
+ getResponseHeader: jest.fn(() => null),
11
+ responseURL: '',
12
+ status: 200,
13
+ responseText: '{}',
14
+ response: '{}',
15
+ onload: null,
16
+ onerror: null
17
+ }
18
+
19
+ const transitionElements = options.transitionElements || []
20
+
21
+ mockDocument = {
22
+ getElementById: jest.fn(),
23
+ querySelectorAll: jest.fn(selector => {
24
+ if (selector === '[odac-transition]') return transitionElements
25
+ return []
26
+ }),
27
+ querySelector: jest.fn(),
28
+ addEventListener: jest.fn(),
29
+ removeEventListener: jest.fn(),
30
+ dispatchEvent: jest.fn(),
31
+ documentElement: {dataset: {}},
32
+ cookie: '',
33
+ readyState: 'complete',
34
+ startViewTransition: options.hasViewTransition
35
+ ? jest.fn(cb => {
36
+ cb()
37
+ return {
38
+ finished: Promise.resolve(),
39
+ ready: Promise.resolve(),
40
+ updateCallbackDone: Promise.resolve()
41
+ }
42
+ })
43
+ : undefined,
44
+ createElement: jest.fn(tag => {
45
+ const el = {
46
+ setAttribute: jest.fn(),
47
+ style: {},
48
+ appendChild: jest.fn(),
49
+ parentNode: {insertBefore: jest.fn()},
50
+ _innerHTML: '',
51
+ get value() {
52
+ if (tag === 'textarea') {
53
+ return this._innerHTML
54
+ .replace(/&/g, '&')
55
+ .replace(/&lt;/g, '<')
56
+ .replace(/&gt;/g, '>')
57
+ .replace(/&quot;/g, '"')
58
+ .replace(/&#039;/g, "'")
59
+ }
60
+ return ''
61
+ }
62
+ }
63
+ Object.defineProperty(el, 'innerHTML', {
64
+ get() {
65
+ return el._innerHTML
66
+ },
67
+ set(v) {
68
+ el._innerHTML = v
69
+ }
70
+ })
71
+ return el
72
+ })
73
+ }
74
+
75
+ mockWindow = {
76
+ location: {protocol: 'http:', host: 'localhost', href: 'http://localhost/', replace: jest.fn()},
77
+ history: {pushState: jest.fn()},
78
+ scrollTo: jest.fn(),
79
+ addEventListener: jest.fn(),
80
+ XMLHttpRequest: jest.fn(() => mockXhr),
81
+ localStorage: {getItem: jest.fn(), setItem: jest.fn(), removeItem: jest.fn()},
82
+ CustomEvent: jest.fn((name, detail) => ({name, detail})),
83
+ setTimeout: jest.fn(),
84
+ clearTimeout: jest.fn(),
85
+ requestAnimationFrame: jest.fn(cb => cb(Date.now())),
86
+ WebSocket: jest.fn(() => ({send: jest.fn(), close: jest.fn(), readyState: 1})),
87
+ FormData: jest.fn(),
88
+ URL: global.URL
89
+ }
90
+ mockWindow.window = mockWindow
91
+ mockWindow.document = mockDocument
92
+ mockWindow.WebSocket.OPEN = 1
93
+ mockWindow.WebSocket.CLOSED = 3
94
+ global.window = mockWindow
95
+ global.document = mockDocument
96
+ global.location = mockWindow.location
97
+ global.XMLHttpRequest = mockWindow.XMLHttpRequest
98
+ global.localStorage = mockWindow.localStorage
99
+ global.CustomEvent = mockWindow.CustomEvent
100
+ global.WebSocket = mockWindow.WebSocket
101
+ global.setTimeout = mockWindow.setTimeout
102
+ global.clearTimeout = mockWindow.clearTimeout
103
+ global.requestAnimationFrame = mockWindow.requestAnimationFrame
104
+ global.FormData = mockWindow.FormData
105
+ delete require.cache[require.resolve('../../client/odac.js')]
106
+ require('../../client/odac.js')
107
+ }
108
+
109
+ afterEach(() => {
110
+ delete global.window
111
+ delete global.document
112
+ delete global.location
113
+ delete global.XMLHttpRequest
114
+ delete global.localStorage
115
+ delete global.CustomEvent
116
+ delete global.WebSocket
117
+ delete global.setTimeout
118
+ delete global.clearTimeout
119
+ delete global.requestAnimationFrame
120
+ delete global.FormData
121
+ delete global.Odac
122
+ })
123
+
124
+ describe('URL validation', () => {
125
+ beforeEach(() => setupMocks())
126
+
127
+ test('should resolve empty string to current URL and proceed with navigation', () => {
128
+ jest.spyOn(window.Odac, 'token').mockReturnValue('mock-token')
129
+ window.Odac.load('', jest.fn())
130
+ // Empty string resolves to current URL via new URL('', base) — AJAX fires normally
131
+ expect(mockXhr.send).toHaveBeenCalled()
132
+ })
133
+
134
+ test('should reject javascript: protocol URLs', () => {
135
+ const result = window.Odac.load('javascript:void(0)', jest.fn())
136
+ expect(result).toBe(false)
137
+ })
138
+
139
+ test('should reject data: protocol URLs', () => {
140
+ const result = window.Odac.load('data:text/html,test', jest.fn())
141
+ expect(result).toBe(false)
142
+ })
143
+
144
+ test('should reject vbscript: protocol URLs', () => {
145
+ const result = window.Odac.load('vbscript:test', jest.fn())
146
+ expect(result).toBe(false)
147
+ })
148
+
149
+ test('should reject hash-only URLs', () => {
150
+ const result = window.Odac.load('#section', jest.fn())
151
+ expect(result).toBe(false)
152
+ })
153
+
154
+ test('should prevent concurrent navigations', () => {
155
+ window.Odac.load('/page-1', jest.fn())
156
+ const result = window.Odac.load('/page-2', jest.fn())
157
+ expect(result).toBe(false)
158
+ })
159
+ })
160
+
161
+ describe('fade fallback path', () => {
162
+ beforeEach(() => setupMocks({hasViewTransition: false}))
163
+
164
+ test('should use fade when View Transition API is unavailable', () => {
165
+ jest.spyOn(window.Odac, 'token').mockReturnValue('mock-token')
166
+ window.Odac.load('/test', jest.fn())
167
+ expect(mockXhr.open).toHaveBeenCalledWith('GET', 'http://localhost/test', true)
168
+ expect(mockXhr.send).toHaveBeenCalled()
169
+ })
170
+
171
+ test('should fallback to window.location.replace on AJAX error', () => {
172
+ jest.spyOn(window.Odac, 'token').mockReturnValue('mock-token')
173
+ window.Odac.load('/error-page', jest.fn())
174
+ mockXhr.onerror()
175
+ expect(mockWindow.location.replace).toHaveBeenCalledWith('http://localhost/error-page')
176
+ })
177
+ })
178
+
179
+ describe('View Transition API path', () => {
180
+ let transitionElements
181
+
182
+ beforeEach(() => {
183
+ transitionElements = [
184
+ {getAttribute: jest.fn(() => 'header'), style: {}, setAttribute: jest.fn()},
185
+ {getAttribute: jest.fn(() => 'hero'), style: {}, setAttribute: jest.fn()}
186
+ ]
187
+ setupMocks({hasViewTransition: true, transitionElements})
188
+ })
189
+
190
+ test('should use View Transition API when supported and odac-transition elements exist', () => {
191
+ jest.spyOn(window.Odac, 'token').mockReturnValue('mock-token')
192
+ window.Odac.load('/new-page', jest.fn())
193
+
194
+ expect(transitionElements[0].style.viewTransitionName).toBe('header')
195
+ expect(transitionElements[1].style.viewTransitionName).toBe('hero')
196
+ expect(mockXhr.send).toHaveBeenCalled()
197
+ })
198
+
199
+ test('should call startViewTransition on successful AJAX response', () => {
200
+ jest.spyOn(window.Odac, 'token').mockReturnValue('mock-token')
201
+ mockXhr.responseURL = 'http://localhost/new-page'
202
+ mockXhr.getResponseHeader.mockImplementation(h => (h === 'Content-Type' ? 'application/json' : null))
203
+
204
+ window.Odac.load('/new-page', jest.fn())
205
+
206
+ mockXhr.responseText = JSON.stringify({title: 'New Page', output: {}})
207
+ mockXhr.status = 200
208
+ if (mockXhr.onload) mockXhr.onload()
209
+
210
+ expect(mockDocument.startViewTransition).toHaveBeenCalled()
211
+ })
212
+
213
+ test('should apply transition names to elements before snapshot', () => {
214
+ jest.spyOn(window.Odac, 'token').mockReturnValue('mock-token')
215
+ window.Odac.load('/page', jest.fn())
216
+
217
+ expect(transitionElements[0].getAttribute).toHaveBeenCalledWith('odac-transition')
218
+ expect(transitionElements[0].style.viewTransitionName).toBe('header')
219
+ expect(transitionElements[1].getAttribute).toHaveBeenCalledWith('odac-transition')
220
+ expect(transitionElements[1].style.viewTransitionName).toBe('hero')
221
+ })
222
+
223
+ test('should fall back to full page load on skeleton change', () => {
224
+ jest.spyOn(window.Odac, 'token').mockReturnValue('mock-token')
225
+ mockXhr.responseURL = 'http://localhost/new-skeleton'
226
+ mockXhr.getResponseHeader.mockImplementation(h => (h === 'Content-Type' ? 'application/json' : null))
227
+
228
+ window.Odac.load('/new-skeleton', jest.fn())
229
+
230
+ mockXhr.responseText = JSON.stringify({skeletonChanged: true})
231
+ mockXhr.status = 200
232
+ if (mockXhr.onload) mockXhr.onload()
233
+
234
+ expect(mockDocument.startViewTransition).not.toHaveBeenCalled()
235
+ expect(mockWindow.location.href).toBe('http://localhost/new-skeleton')
236
+ })
237
+
238
+ test('should clean transition names on AJAX error', () => {
239
+ jest.spyOn(window.Odac, 'token').mockReturnValue('mock-token')
240
+ window.Odac.load('/fail', jest.fn())
241
+
242
+ mockXhr.onerror()
243
+
244
+ expect(transitionElements[0].style.viewTransitionName).toBe('')
245
+ expect(transitionElements[1].style.viewTransitionName).toBe('')
246
+ })
247
+ })
248
+
249
+ describe('fade fallback when no odac-transition elements', () => {
250
+ beforeEach(() => setupMocks({hasViewTransition: true, transitionElements: []}))
251
+
252
+ test('should use fade path when API exists but no odac-transition elements found', () => {
253
+ jest.spyOn(window.Odac, 'token').mockReturnValue('mock-token')
254
+ window.Odac.load('/page', jest.fn())
255
+
256
+ expect(mockDocument.startViewTransition).not.toHaveBeenCalled()
257
+ expect(mockXhr.send).toHaveBeenCalled()
258
+ })
259
+ })
260
+
261
+ describe('title HTML entity decoding', () => {
262
+ beforeEach(() => setupMocks({hasViewTransition: false}))
263
+
264
+ test('should decode HTML entities in title during fade navigation', () => {
265
+ jest.spyOn(window.Odac, 'token').mockReturnValue('mock-token')
266
+ mockXhr.responseURL = 'http://localhost/products'
267
+ mockXhr.getResponseHeader.mockImplementation(h => (h === 'Content-Type' ? 'application/json' : null))
268
+
269
+ window.Odac.load('/products', jest.fn())
270
+
271
+ mockXhr.responseText = JSON.stringify({title: 'Tom &amp; Jerry', output: {}})
272
+ mockXhr.status = 200
273
+ mockXhr.onload()
274
+
275
+ expect(mockDocument.title).toBe('Tom & Jerry')
276
+ })
277
+
278
+ test('should decode multiple HTML entities in title', () => {
279
+ jest.spyOn(window.Odac, 'token').mockReturnValue('mock-token')
280
+ mockXhr.responseURL = 'http://localhost/page'
281
+ mockXhr.getResponseHeader.mockImplementation(h => (h === 'Content-Type' ? 'application/json' : null))
282
+
283
+ window.Odac.load('/page', jest.fn())
284
+
285
+ mockXhr.responseText = JSON.stringify({title: '&lt;ODAC&gt; &amp; &quot;Framework&quot;', output: {}})
286
+ mockXhr.status = 200
287
+ mockXhr.onload()
288
+
289
+ expect(mockDocument.title).toBe('<ODAC> & "Framework"')
290
+ })
291
+
292
+ test('should handle title without entities unchanged', () => {
293
+ jest.spyOn(window.Odac, 'token').mockReturnValue('mock-token')
294
+ mockXhr.responseURL = 'http://localhost/simple'
295
+ mockXhr.getResponseHeader.mockImplementation(h => (h === 'Content-Type' ? 'application/json' : null))
296
+
297
+ window.Odac.load('/simple', jest.fn())
298
+
299
+ mockXhr.responseText = JSON.stringify({title: 'Simple Page Title', output: {}})
300
+ mockXhr.status = 200
301
+ mockXhr.onload()
302
+
303
+ expect(mockDocument.title).toBe('Simple Page Title')
304
+ })
305
+ })
306
+ })