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.
- package/.agent/rules/memory.md +5 -1
- package/CHANGELOG.md +28 -0
- package/README.md +2 -1
- package/client/odac.js +121 -2
- package/docs/ai/skills/backend/views.md +34 -9
- package/docs/ai/skills/frontend/navigation.md +45 -1
- package/docs/backend/07-views/03-template-syntax.md +48 -14
- package/docs/backend/07-views/03-variables.md +22 -7
- package/docs/frontend/02-ajax-navigation/01-quick-start.md +22 -0
- package/docs/frontend/02-ajax-navigation/03-advanced-usage.md +51 -0
- package/package.json +1 -1
- package/template/controller/page/about.js +3 -3
- package/template/controller/page/index.js +2 -2
- package/template/public/assets/js/app.js +38 -54
- package/template/skeleton/main.html +4 -4
- package/template/view/content/about.html +64 -60
- package/template/view/content/home.html +148 -175
- package/template/view/css/app.css +46 -0
- package/template/view/footer/main.html +10 -9
- package/template/view/header/main.html +34 -11
- package/test/Client/load.test.js +306 -0
- package/template/public/assets/css/style.css +0 -1835
|
@@ -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(/</g, '<')
|
|
56
|
+
.replace(/>/g, '>')
|
|
57
|
+
.replace(/"/g, '"')
|
|
58
|
+
.replace(/'/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 & 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: '<ODAC> & "Framework"', 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
|
+
})
|