odac 1.4.4 → 1.4.6
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 +10 -1
- package/.releaserc.js +1 -1
- package/CHANGELOG.md +50 -0
- package/README.md +2 -1
- package/client/odac.js +228 -62
- package/docs/ai/skills/backend/views.md +102 -30
- package/docs/ai/skills/frontend/core.md +17 -3
- package/docs/ai/skills/frontend/navigation.md +105 -8
- package/docs/backend/07-views/01-the-view-directory.md +28 -6
- package/docs/backend/07-views/02-rendering-a-view.md +16 -23
- 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 +30 -6
- package/src/Request.js +5 -4
- package/src/Route.js +11 -0
- package/src/View.js +54 -9
- 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/test/Database/ConnectionFactory/buildConnections.test.js +4 -0
- package/test/View/parseOdacTag.test.js +180 -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
|
+
})
|
|
@@ -6,6 +6,10 @@ jest.mock(
|
|
|
6
6
|
mockKnex(...args)
|
|
7
7
|
)
|
|
8
8
|
|
|
9
|
+
jest.mock('mysql2', () => ({}), {virtual: true})
|
|
10
|
+
jest.mock('pg', () => ({}), {virtual: true})
|
|
11
|
+
jest.mock('sqlite3', () => ({}), {virtual: true})
|
|
12
|
+
|
|
9
13
|
const {buildConnections} = require('../../../src/Database/ConnectionFactory')
|
|
10
14
|
|
|
11
15
|
describe('ConnectionFactory.buildConnections()', () => {
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
const fs = require('fs')
|
|
2
|
+
const fsPromises = fs.promises
|
|
3
|
+
const path = require('path')
|
|
4
|
+
const View = require('../../src/View')
|
|
5
|
+
|
|
6
|
+
const FIXTURE_DIR = path.resolve(__dirname, '_fixtures')
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Integration tests for the #parseOdacTag private method.
|
|
10
|
+
* Since #parseOdacTag is private, we test it indirectly through the
|
|
11
|
+
* full render pipeline by creating temporary .html view files,
|
|
12
|
+
* invoking View.print(), and asserting the rendered output.
|
|
13
|
+
*/
|
|
14
|
+
describe('View.#parseOdacTag()', () => {
|
|
15
|
+
let originalDir
|
|
16
|
+
let originalCwd
|
|
17
|
+
|
|
18
|
+
beforeAll(async () => {
|
|
19
|
+
await fsPromises.mkdir(path.join(FIXTURE_DIR, 'skeleton'), {recursive: true})
|
|
20
|
+
await fsPromises.mkdir(path.join(FIXTURE_DIR, 'view', 'content'), {recursive: true})
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
afterAll(async () => {
|
|
24
|
+
await fsPromises.rm(FIXTURE_DIR, {recursive: true, force: true})
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
beforeEach(() => {
|
|
28
|
+
originalDir = global.__dir
|
|
29
|
+
originalCwd = process.cwd()
|
|
30
|
+
global.__dir = FIXTURE_DIR
|
|
31
|
+
process.chdir(FIXTURE_DIR)
|
|
32
|
+
|
|
33
|
+
if (global.Odac?.View?.cache) {
|
|
34
|
+
global.Odac.View.cache = {}
|
|
35
|
+
}
|
|
36
|
+
if (global.Odac?.View?.skeletons) {
|
|
37
|
+
global.Odac.View.skeletons = {}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Clear require cache for compiled templates
|
|
41
|
+
for (const key of Object.keys(require.cache)) {
|
|
42
|
+
if (key.includes('.cache')) {
|
|
43
|
+
delete require.cache[key]
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
afterEach(() => {
|
|
49
|
+
global.__dir = originalDir
|
|
50
|
+
process.chdir(originalCwd)
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
let testCounter = 0
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Writes a view file, triggers render via print(), and captures output.
|
|
57
|
+
* Uses a unique filename per call to avoid cache collisions between tests.
|
|
58
|
+
* Returns the rendered HTML string.
|
|
59
|
+
*/
|
|
60
|
+
async function renderTemplate(templateContent) {
|
|
61
|
+
const uniqueId = `test_${++testCounter}`
|
|
62
|
+
const viewFile = path.join(FIXTURE_DIR, 'view', 'content', `${uniqueId}.html`)
|
|
63
|
+
await fsPromises.writeFile(viewFile, templateContent, 'utf8')
|
|
64
|
+
|
|
65
|
+
const skeletonFile = path.join(FIXTURE_DIR, 'skeleton', 'main.html')
|
|
66
|
+
await fsPromises.writeFile(skeletonFile, '{{ CONTENT }}', 'utf8')
|
|
67
|
+
|
|
68
|
+
let capturedOutput = ''
|
|
69
|
+
const errors = []
|
|
70
|
+
const originalError = console.error
|
|
71
|
+
console.error = (...args) => errors.push(args.join(' '))
|
|
72
|
+
|
|
73
|
+
const mockOdac = {
|
|
74
|
+
Config: {debug: true},
|
|
75
|
+
Var: value => {
|
|
76
|
+
const str = value === null || value === undefined ? '' : String(value)
|
|
77
|
+
return {
|
|
78
|
+
html: () => str.replace(/[&<>"']/g, m => ({'&': '&', '<': '<', '>': '>', '"': '"', "'": '''})[m])
|
|
79
|
+
}
|
|
80
|
+
},
|
|
81
|
+
Request: {
|
|
82
|
+
req: {url: '/test'},
|
|
83
|
+
res: {finished: false, headersSent: false},
|
|
84
|
+
isAjaxLoad: false,
|
|
85
|
+
ajaxLoad: [],
|
|
86
|
+
variables: {},
|
|
87
|
+
sharedData: {},
|
|
88
|
+
page: '',
|
|
89
|
+
get: () => '',
|
|
90
|
+
header: () => {},
|
|
91
|
+
end: output => {
|
|
92
|
+
capturedOutput = output
|
|
93
|
+
},
|
|
94
|
+
hasEarlyHints: () => false,
|
|
95
|
+
setEarlyHints: () => {}
|
|
96
|
+
},
|
|
97
|
+
Lang: {
|
|
98
|
+
get: (...args) => args[0]
|
|
99
|
+
},
|
|
100
|
+
View: {}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
try {
|
|
104
|
+
const view = new View(mockOdac)
|
|
105
|
+
view.skeleton('main')
|
|
106
|
+
view.set('content', uniqueId)
|
|
107
|
+
await view.print()
|
|
108
|
+
} finally {
|
|
109
|
+
console.error = originalError
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
if (errors.length > 0) {
|
|
113
|
+
throw new Error(`Template render error: ${errors.join('\n')}`)
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
return capturedOutput
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
describe('single quote escaping in <odac> tags', () => {
|
|
120
|
+
it('should render a single quote inside <odac> without syntax error', async () => {
|
|
121
|
+
const result = await renderTemplate("<odac>'</odac>")
|
|
122
|
+
// Single quotes are HTML-escaped by Odac.Var().html()
|
|
123
|
+
expect(result).toContain(''')
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
it('should render text with embedded single quotes', async () => {
|
|
127
|
+
const result = await renderTemplate("<odac>it's working</odac>")
|
|
128
|
+
expect(result).toContain('it's working')
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
it('should render multiple single quotes', async () => {
|
|
132
|
+
const result = await renderTemplate("<odac>it's a developer's life</odac>")
|
|
133
|
+
expect(result).toContain('it's a developer's life')
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
it('should render escaped apostrophe in translation tags', async () => {
|
|
137
|
+
const result = await renderTemplate("<odac t>it's translated</odac>")
|
|
138
|
+
expect(result).toContain('it's translated')
|
|
139
|
+
})
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
describe('basic <odac> tag rendering', () => {
|
|
143
|
+
it('should render plain text inside <odac> tags', async () => {
|
|
144
|
+
const result = await renderTemplate('<odac>hello world</odac>')
|
|
145
|
+
expect(result).toContain('hello world')
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
it('should strip backend comments (multi-line)', async () => {
|
|
149
|
+
const result = await renderTemplate('visible<!--odac hidden odac-->visible2')
|
|
150
|
+
expect(result).toContain('visible')
|
|
151
|
+
expect(result).toContain('visible2')
|
|
152
|
+
expect(result).not.toContain('hidden')
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
it('should strip backend comments (single-line)', async () => {
|
|
156
|
+
const result = await renderTemplate('visible<!--odac hidden -->visible2')
|
|
157
|
+
expect(result).toContain('visible')
|
|
158
|
+
expect(result).toContain('visible2')
|
|
159
|
+
expect(result).not.toContain('hidden')
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
it('should handle empty <odac> tags gracefully', async () => {
|
|
163
|
+
const result = await renderTemplate('<odac></odac>')
|
|
164
|
+
expect(typeof result).toBe('string')
|
|
165
|
+
})
|
|
166
|
+
})
|
|
167
|
+
|
|
168
|
+
describe('special characters in <odac> tags', () => {
|
|
169
|
+
it('should handle double quotes inside <odac> tags', async () => {
|
|
170
|
+
const result = await renderTemplate('<odac>say "hello"</odac>')
|
|
171
|
+
expect(result).toContain('say')
|
|
172
|
+
})
|
|
173
|
+
|
|
174
|
+
it('should handle angle brackets in text (HTML entities)', async () => {
|
|
175
|
+
const result = await renderTemplate('<odac><div></odac>')
|
|
176
|
+
// Already-escaped entities get double-escaped by Odac.Var().html()
|
|
177
|
+
expect(result).toContain('&lt;div&gt;')
|
|
178
|
+
})
|
|
179
|
+
})
|
|
180
|
+
})
|