@pyreon/runtime-dom 0.11.4 → 0.11.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/README.md +16 -22
- package/lib/analysis/index.js.html +1 -1
- package/lib/index.js +4 -3
- package/lib/index.js.map +1 -1
- package/package.json +12 -12
- package/src/delegate.ts +25 -25
- package/src/devtools.ts +37 -37
- package/src/hydrate.ts +30 -30
- package/src/hydration-debug.ts +2 -2
- package/src/index.ts +20 -20
- package/src/keep-alive.ts +6 -6
- package/src/mount.ts +46 -46
- package/src/nodes.ts +31 -19
- package/src/props.ts +93 -93
- package/src/template.ts +6 -6
- package/src/tests/coverage-gaps.test.ts +669 -669
- package/src/tests/coverage.test.ts +299 -299
- package/src/tests/mount.test.ts +1183 -1183
- package/src/tests/props.test.ts +219 -219
- package/src/tests/setup.ts +1 -1
- package/src/tests/show-context.test.ts +43 -43
- package/src/tests/template.test.ts +71 -71
- package/src/tests/transition.test.ts +124 -124
- package/src/transition-group.ts +22 -22
- package/src/transition.ts +18 -18
package/src/tests/props.test.ts
CHANGED
|
@@ -1,44 +1,44 @@
|
|
|
1
|
-
import { signal } from
|
|
2
|
-
import { DELEGATED_EVENTS, delegatedPropName, setupDelegation } from
|
|
3
|
-
import { applyProp, applyProps, sanitizeHtml, setSanitizer } from
|
|
1
|
+
import { signal } from '@pyreon/reactivity'
|
|
2
|
+
import { DELEGATED_EVENTS, delegatedPropName, setupDelegation } from '../delegate'
|
|
3
|
+
import { applyProp, applyProps, sanitizeHtml, setSanitizer } from '../props'
|
|
4
4
|
|
|
5
5
|
// ─── applyProps ──────────────────────────────────────────────────────────────
|
|
6
6
|
|
|
7
|
-
describe(
|
|
8
|
-
test(
|
|
9
|
-
const el = document.createElement(
|
|
7
|
+
describe('applyProps', () => {
|
|
8
|
+
test('skips key, ref, and children props', () => {
|
|
9
|
+
const el = document.createElement('div')
|
|
10
10
|
const cleanup = applyProps(el, {
|
|
11
|
-
key:
|
|
11
|
+
key: 'k1',
|
|
12
12
|
ref: { current: null },
|
|
13
|
-
children:
|
|
14
|
-
id:
|
|
13
|
+
children: 'text',
|
|
14
|
+
id: 'test',
|
|
15
15
|
})
|
|
16
|
-
expect(el.getAttribute(
|
|
16
|
+
expect(el.getAttribute('id')).toBe('test')
|
|
17
17
|
// key, ref, children should not appear as attributes
|
|
18
|
-
expect(el.hasAttribute(
|
|
19
|
-
expect(el.hasAttribute(
|
|
20
|
-
expect(el.hasAttribute(
|
|
18
|
+
expect(el.hasAttribute('key')).toBe(false)
|
|
19
|
+
expect(el.hasAttribute('ref')).toBe(false)
|
|
20
|
+
expect(el.hasAttribute('children')).toBe(false)
|
|
21
21
|
cleanup?.()
|
|
22
22
|
})
|
|
23
23
|
|
|
24
|
-
test(
|
|
25
|
-
const el = document.createElement(
|
|
26
|
-
const cleanup = applyProps(el, { id:
|
|
24
|
+
test('returns null when no props produce cleanup', () => {
|
|
25
|
+
const el = document.createElement('div')
|
|
26
|
+
const cleanup = applyProps(el, { id: 'static', title: 'hello' })
|
|
27
27
|
expect(cleanup).toBeNull()
|
|
28
28
|
})
|
|
29
29
|
|
|
30
|
-
test(
|
|
31
|
-
const el = document.createElement(
|
|
30
|
+
test('returns single cleanup when one prop needs it', () => {
|
|
31
|
+
const el = document.createElement('div')
|
|
32
32
|
const cleanup = applyProps(el, { onClick: () => {} })
|
|
33
33
|
expect(cleanup).not.toBeNull()
|
|
34
|
-
expect(typeof cleanup).toBe(
|
|
34
|
+
expect(typeof cleanup).toBe('function')
|
|
35
35
|
cleanup?.()
|
|
36
36
|
})
|
|
37
37
|
|
|
38
|
-
test(
|
|
39
|
-
const el = document.createElement(
|
|
40
|
-
const s1 = signal(
|
|
41
|
-
const s2 = signal(
|
|
38
|
+
test('returns chained cleanup when multiple props need cleanup', () => {
|
|
39
|
+
const el = document.createElement('div')
|
|
40
|
+
const s1 = signal('a')
|
|
41
|
+
const s2 = signal('b')
|
|
42
42
|
const cleanup = applyProps(el, {
|
|
43
43
|
onClick: () => {},
|
|
44
44
|
class: s1,
|
|
@@ -48,223 +48,223 @@ describe("applyProps", () => {
|
|
|
48
48
|
cleanup?.()
|
|
49
49
|
})
|
|
50
50
|
|
|
51
|
-
test(
|
|
52
|
-
const el = document.createElement(
|
|
51
|
+
test('chains 3+ cleanups into array-based cleanup', () => {
|
|
52
|
+
const el = document.createElement('div')
|
|
53
53
|
const cleanup = applyProps(el, {
|
|
54
54
|
onClick: () => {},
|
|
55
55
|
onInput: () => {},
|
|
56
|
-
class: signal(
|
|
56
|
+
class: signal('x'),
|
|
57
57
|
})
|
|
58
|
-
expect(typeof cleanup).toBe(
|
|
58
|
+
expect(typeof cleanup).toBe('function')
|
|
59
59
|
cleanup?.()
|
|
60
60
|
})
|
|
61
61
|
})
|
|
62
62
|
|
|
63
63
|
// ─── applyProp — style ────────────────────────────────────────────────────────
|
|
64
64
|
|
|
65
|
-
describe(
|
|
66
|
-
test(
|
|
67
|
-
const el = document.createElement(
|
|
68
|
-
applyProp(el,
|
|
69
|
-
expect(el.style.cssText).toContain(
|
|
65
|
+
describe('applyProp — style', () => {
|
|
66
|
+
test('applies style as string via cssText', () => {
|
|
67
|
+
const el = document.createElement('div')
|
|
68
|
+
applyProp(el, 'style', 'color: red; font-size: 14px')
|
|
69
|
+
expect(el.style.cssText).toContain('color')
|
|
70
70
|
})
|
|
71
71
|
|
|
72
|
-
test(
|
|
73
|
-
const el = document.createElement(
|
|
74
|
-
applyProp(el,
|
|
72
|
+
test('applies style as object with camelCase properties', () => {
|
|
73
|
+
const el = document.createElement('div')
|
|
74
|
+
applyProp(el, 'style', { fontSize: '14px', color: 'blue' })
|
|
75
75
|
// Check that setProperty was called (kebab-case conversion)
|
|
76
|
-
expect(el.style.getPropertyValue(
|
|
76
|
+
expect(el.style.getPropertyValue('font-size') || el.style.fontSize).toBeTruthy()
|
|
77
77
|
})
|
|
78
78
|
|
|
79
|
-
test(
|
|
80
|
-
const el = document.createElement(
|
|
81
|
-
applyProp(el,
|
|
82
|
-
expect(el.style.getPropertyValue(
|
|
79
|
+
test('applies style with CSS custom properties (--var)', () => {
|
|
80
|
+
const el = document.createElement('div')
|
|
81
|
+
applyProp(el, 'style', { '--main-color': 'red' })
|
|
82
|
+
expect(el.style.getPropertyValue('--main-color')).toBe('red')
|
|
83
83
|
})
|
|
84
84
|
|
|
85
|
-
test(
|
|
86
|
-
const el = document.createElement(
|
|
85
|
+
test('ignores null/undefined style', () => {
|
|
86
|
+
const el = document.createElement('div')
|
|
87
87
|
// null style removes the attribute
|
|
88
|
-
applyProp(el,
|
|
89
|
-
expect(el.hasAttribute(
|
|
88
|
+
applyProp(el, 'style', null)
|
|
89
|
+
expect(el.hasAttribute('style')).toBe(false)
|
|
90
90
|
})
|
|
91
91
|
})
|
|
92
92
|
|
|
93
93
|
// ─── applyProp — class ───────────────────────────────────────────────────────
|
|
94
94
|
|
|
95
|
-
describe(
|
|
96
|
-
test(
|
|
97
|
-
const el = document.createElement(
|
|
98
|
-
applyProp(el,
|
|
99
|
-
expect(el.getAttribute(
|
|
95
|
+
describe('applyProp — class', () => {
|
|
96
|
+
test('applies class as string', () => {
|
|
97
|
+
const el = document.createElement('div')
|
|
98
|
+
applyProp(el, 'class', 'foo bar')
|
|
99
|
+
expect(el.getAttribute('class')).toBe('foo bar')
|
|
100
100
|
})
|
|
101
101
|
|
|
102
|
-
test(
|
|
103
|
-
const el = document.createElement(
|
|
104
|
-
applyProp(el,
|
|
105
|
-
expect(el.getAttribute(
|
|
102
|
+
test('applies class as array', () => {
|
|
103
|
+
const el = document.createElement('div')
|
|
104
|
+
applyProp(el, 'class', ['foo', 'bar'])
|
|
105
|
+
expect(el.getAttribute('class')).toBe('foo bar')
|
|
106
106
|
})
|
|
107
107
|
|
|
108
|
-
test(
|
|
109
|
-
const el = document.createElement(
|
|
110
|
-
applyProp(el,
|
|
111
|
-
const cls = el.getAttribute(
|
|
112
|
-
expect(cls).toContain(
|
|
113
|
-
expect(cls).toContain(
|
|
114
|
-
expect(cls).not.toContain(
|
|
108
|
+
test('applies class as object (conditionals)', () => {
|
|
109
|
+
const el = document.createElement('div')
|
|
110
|
+
applyProp(el, 'class', { active: true, disabled: false, highlight: true })
|
|
111
|
+
const cls = el.getAttribute('class') ?? ''
|
|
112
|
+
expect(cls).toContain('active')
|
|
113
|
+
expect(cls).toContain('highlight')
|
|
114
|
+
expect(cls).not.toContain('disabled')
|
|
115
115
|
})
|
|
116
116
|
|
|
117
|
-
test(
|
|
118
|
-
const el = document.createElement(
|
|
119
|
-
applyProp(el,
|
|
120
|
-
expect(el.getAttribute(
|
|
117
|
+
test('applies className as alias for class', () => {
|
|
118
|
+
const el = document.createElement('div')
|
|
119
|
+
applyProp(el, 'className', 'my-class')
|
|
120
|
+
expect(el.getAttribute('class')).toBe('my-class')
|
|
121
121
|
})
|
|
122
122
|
|
|
123
|
-
test(
|
|
124
|
-
const el = document.createElement(
|
|
125
|
-
applyProp(el,
|
|
126
|
-
expect(el.getAttribute(
|
|
123
|
+
test('sets empty class attribute when value resolves to empty', () => {
|
|
124
|
+
const el = document.createElement('div')
|
|
125
|
+
applyProp(el, 'class', '')
|
|
126
|
+
expect(el.getAttribute('class')).toBe('')
|
|
127
127
|
})
|
|
128
128
|
})
|
|
129
129
|
|
|
130
130
|
// ─── applyProp — events ──────────────────────────────────────────────────────
|
|
131
131
|
|
|
132
|
-
describe(
|
|
133
|
-
test(
|
|
134
|
-
const el = document.createElement(
|
|
132
|
+
describe('applyProp — events', () => {
|
|
133
|
+
test('adds non-delegated event listener and returns cleanup', () => {
|
|
134
|
+
const el = document.createElement('div')
|
|
135
135
|
const handler = vi.fn()
|
|
136
136
|
// onScroll is non-delegated (scroll doesn't bubble)
|
|
137
|
-
const cleanup = applyProp(el,
|
|
137
|
+
const cleanup = applyProp(el, 'onScroll', handler)
|
|
138
138
|
expect(cleanup).not.toBeNull()
|
|
139
139
|
// Dispatch scroll event — non-delegated events use addEventListener directly
|
|
140
|
-
el.dispatchEvent(new Event(
|
|
140
|
+
el.dispatchEvent(new Event('scroll'))
|
|
141
141
|
expect(handler).toHaveBeenCalled()
|
|
142
142
|
cleanup?.()
|
|
143
143
|
})
|
|
144
144
|
|
|
145
|
-
test(
|
|
146
|
-
const el = document.createElement(
|
|
145
|
+
test('adds delegated event via expando property', () => {
|
|
146
|
+
const el = document.createElement('div')
|
|
147
147
|
const handler = vi.fn()
|
|
148
|
-
const cleanup = applyProp(el,
|
|
148
|
+
const cleanup = applyProp(el, 'onClick', handler)
|
|
149
149
|
expect(cleanup).not.toBeNull()
|
|
150
150
|
// Check that the delegated expando property is set
|
|
151
|
-
const prop = delegatedPropName(
|
|
152
|
-
expect(typeof (el as unknown as Record<string, unknown>)[prop]).toBe(
|
|
151
|
+
const prop = delegatedPropName('click')
|
|
152
|
+
expect(typeof (el as unknown as Record<string, unknown>)[prop]).toBe('function')
|
|
153
153
|
cleanup?.()
|
|
154
154
|
// After cleanup, expando should be undefined
|
|
155
155
|
expect((el as unknown as Record<string, unknown>)[prop]).toBeUndefined()
|
|
156
156
|
})
|
|
157
157
|
|
|
158
|
-
test(
|
|
159
|
-
const el = document.createElement(
|
|
160
|
-
const warnSpy = vi.spyOn(console,
|
|
161
|
-
const cleanup = applyProp(el,
|
|
162
|
-
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining(
|
|
158
|
+
test('warns on non-function event handler in dev mode', () => {
|
|
159
|
+
const el = document.createElement('div')
|
|
160
|
+
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
|
161
|
+
const cleanup = applyProp(el, 'onClick', 'not-a-function')
|
|
162
|
+
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('non-function'))
|
|
163
163
|
expect(cleanup).toBeNull()
|
|
164
164
|
warnSpy.mockRestore()
|
|
165
165
|
})
|
|
166
166
|
|
|
167
|
-
test(
|
|
168
|
-
const el = document.createElement(
|
|
167
|
+
test('cleanup removes non-delegated event listener', () => {
|
|
168
|
+
const el = document.createElement('div')
|
|
169
169
|
const handler = vi.fn()
|
|
170
|
-
const cleanup = applyProp(el,
|
|
170
|
+
const cleanup = applyProp(el, 'onScroll', handler)
|
|
171
171
|
cleanup?.()
|
|
172
|
-
el.dispatchEvent(new Event(
|
|
172
|
+
el.dispatchEvent(new Event('scroll'))
|
|
173
173
|
expect(handler).not.toHaveBeenCalled()
|
|
174
174
|
})
|
|
175
175
|
})
|
|
176
176
|
|
|
177
177
|
// ─── applyProp — reactive (function) values ──────────────────────────────────
|
|
178
178
|
|
|
179
|
-
describe(
|
|
180
|
-
test(
|
|
181
|
-
const el = document.createElement(
|
|
182
|
-
const s = signal(
|
|
183
|
-
const cleanup = applyProp(el,
|
|
184
|
-
expect(el.getAttribute(
|
|
185
|
-
s.set(
|
|
186
|
-
expect(el.getAttribute(
|
|
179
|
+
describe('applyProp — reactive values', () => {
|
|
180
|
+
test('wraps function value in renderEffect', () => {
|
|
181
|
+
const el = document.createElement('div')
|
|
182
|
+
const s = signal('hello')
|
|
183
|
+
const cleanup = applyProp(el, 'title', () => s())
|
|
184
|
+
expect(el.getAttribute('title')).toBe('hello')
|
|
185
|
+
s.set('world')
|
|
186
|
+
expect(el.getAttribute('title')).toBe('world')
|
|
187
187
|
cleanup?.()
|
|
188
188
|
})
|
|
189
189
|
|
|
190
|
-
test(
|
|
191
|
-
const el = document.createElement(
|
|
192
|
-
const s = signal(
|
|
193
|
-
const cleanup = applyProp(el,
|
|
190
|
+
test('stops tracking after disposal', () => {
|
|
191
|
+
const el = document.createElement('div')
|
|
192
|
+
const s = signal('a')
|
|
193
|
+
const cleanup = applyProp(el, 'title', () => s())
|
|
194
194
|
cleanup?.()
|
|
195
|
-
s.set(
|
|
196
|
-
expect(el.getAttribute(
|
|
195
|
+
s.set('b')
|
|
196
|
+
expect(el.getAttribute('title')).toBe('a')
|
|
197
197
|
})
|
|
198
198
|
})
|
|
199
199
|
|
|
200
200
|
// ─── applyProp — static values ───────────────────────────────────────────────
|
|
201
201
|
|
|
202
|
-
describe(
|
|
203
|
-
test(
|
|
204
|
-
const el = document.createElement(
|
|
205
|
-
applyProp(el,
|
|
206
|
-
expect(el.getAttribute(
|
|
202
|
+
describe('applyProp — static values', () => {
|
|
203
|
+
test('sets string attribute', () => {
|
|
204
|
+
const el = document.createElement('div')
|
|
205
|
+
applyProp(el, 'data-testid', 'hello')
|
|
206
|
+
expect(el.getAttribute('data-testid')).toBe('hello')
|
|
207
207
|
})
|
|
208
208
|
|
|
209
|
-
test(
|
|
210
|
-
const el = document.createElement(
|
|
211
|
-
el.setAttribute(
|
|
212
|
-
applyProp(el,
|
|
213
|
-
expect(el.hasAttribute(
|
|
209
|
+
test('removes attribute when value is null', () => {
|
|
210
|
+
const el = document.createElement('div')
|
|
211
|
+
el.setAttribute('data-x', 'val')
|
|
212
|
+
applyProp(el, 'data-x', null)
|
|
213
|
+
expect(el.hasAttribute('data-x')).toBe(false)
|
|
214
214
|
})
|
|
215
215
|
|
|
216
|
-
test(
|
|
217
|
-
const el = document.createElement(
|
|
218
|
-
el.setAttribute(
|
|
219
|
-
applyProp(el,
|
|
220
|
-
expect(el.hasAttribute(
|
|
216
|
+
test('removes attribute when value is undefined', () => {
|
|
217
|
+
const el = document.createElement('div')
|
|
218
|
+
el.setAttribute('data-x', 'val')
|
|
219
|
+
applyProp(el, 'data-x', undefined)
|
|
220
|
+
expect(el.hasAttribute('data-x')).toBe(false)
|
|
221
221
|
})
|
|
222
222
|
|
|
223
|
-
test(
|
|
224
|
-
const el = document.createElement(
|
|
225
|
-
applyProp(el,
|
|
226
|
-
expect(el.hasAttribute(
|
|
227
|
-
expect(el.getAttribute(
|
|
223
|
+
test('sets boolean true as empty attribute', () => {
|
|
224
|
+
const el = document.createElement('input') as HTMLInputElement
|
|
225
|
+
applyProp(el, 'disabled', true)
|
|
226
|
+
expect(el.hasAttribute('disabled')).toBe(true)
|
|
227
|
+
expect(el.getAttribute('disabled')).toBe('')
|
|
228
228
|
})
|
|
229
229
|
|
|
230
|
-
test(
|
|
231
|
-
const el = document.createElement(
|
|
232
|
-
el.setAttribute(
|
|
233
|
-
applyProp(el,
|
|
234
|
-
expect(el.hasAttribute(
|
|
230
|
+
test('removes attribute for boolean false', () => {
|
|
231
|
+
const el = document.createElement('input') as HTMLInputElement
|
|
232
|
+
el.setAttribute('disabled', '')
|
|
233
|
+
applyProp(el, 'disabled', false)
|
|
234
|
+
expect(el.hasAttribute('disabled')).toBe(false)
|
|
235
235
|
})
|
|
236
236
|
|
|
237
|
-
test(
|
|
238
|
-
const el = document.createElement(
|
|
239
|
-
applyProp(el,
|
|
240
|
-
expect(el.value).toBe(
|
|
237
|
+
test('sets DOM property directly when key exists on element', () => {
|
|
238
|
+
const el = document.createElement('input') as HTMLInputElement
|
|
239
|
+
applyProp(el, 'value', 'hello')
|
|
240
|
+
expect(el.value).toBe('hello')
|
|
241
241
|
})
|
|
242
242
|
|
|
243
|
-
test(
|
|
244
|
-
const el = document.createElement(
|
|
245
|
-
applyProp(el,
|
|
246
|
-
expect(el.getAttribute(
|
|
243
|
+
test('falls back to setAttribute for unknown attributes', () => {
|
|
244
|
+
const el = document.createElement('div')
|
|
245
|
+
applyProp(el, 'data-custom', 42)
|
|
246
|
+
expect(el.getAttribute('data-custom')).toBe('42')
|
|
247
247
|
})
|
|
248
248
|
})
|
|
249
249
|
|
|
250
250
|
// ─── applyProp — innerHTML / dangerouslySetInnerHTML ─────────────────────────
|
|
251
251
|
|
|
252
|
-
describe(
|
|
253
|
-
test(
|
|
254
|
-
const el = document.createElement(
|
|
255
|
-
applyProp(el,
|
|
252
|
+
describe('applyProp — innerHTML', () => {
|
|
253
|
+
test('innerHTML is sanitized', () => {
|
|
254
|
+
const el = document.createElement('div')
|
|
255
|
+
applyProp(el, 'innerHTML', '<b>bold</b><script>alert("xss")</script>')
|
|
256
256
|
// Script tag should be stripped by sanitizer
|
|
257
|
-
expect(el.innerHTML).toContain(
|
|
258
|
-
expect(el.innerHTML).not.toContain(
|
|
257
|
+
expect(el.innerHTML).toContain('<b>bold</b>')
|
|
258
|
+
expect(el.innerHTML).not.toContain('<script>')
|
|
259
259
|
})
|
|
260
260
|
|
|
261
|
-
test(
|
|
262
|
-
const el = document.createElement(
|
|
263
|
-
const warnSpy = vi.spyOn(console,
|
|
264
|
-
applyProp(el,
|
|
265
|
-
expect(el.innerHTML).toBe(
|
|
261
|
+
test('dangerouslySetInnerHTML bypasses sanitization', () => {
|
|
262
|
+
const el = document.createElement('div')
|
|
263
|
+
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
|
264
|
+
applyProp(el, 'dangerouslySetInnerHTML', { __html: '<em>raw</em>' })
|
|
265
|
+
expect(el.innerHTML).toBe('<em>raw</em>')
|
|
266
266
|
expect(warnSpy).toHaveBeenCalledWith(
|
|
267
|
-
expect.stringContaining(
|
|
267
|
+
expect.stringContaining('dangerouslySetInnerHTML bypasses sanitization'),
|
|
268
268
|
)
|
|
269
269
|
warnSpy.mockRestore()
|
|
270
270
|
})
|
|
@@ -272,101 +272,101 @@ describe("applyProp — innerHTML", () => {
|
|
|
272
272
|
|
|
273
273
|
// ─── applyProp — URL safety ──────────────────────────────────────────────────
|
|
274
274
|
|
|
275
|
-
describe(
|
|
276
|
-
test(
|
|
277
|
-
const el = document.createElement(
|
|
278
|
-
const warnSpy = vi.spyOn(console,
|
|
279
|
-
applyProp(el,
|
|
280
|
-
expect(el.hasAttribute(
|
|
275
|
+
describe('applyProp — URL safety', () => {
|
|
276
|
+
test('blocks javascript: in href', () => {
|
|
277
|
+
const el = document.createElement('a')
|
|
278
|
+
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
|
279
|
+
applyProp(el, 'href', 'javascript:alert(1)')
|
|
280
|
+
expect(el.hasAttribute('href')).toBe(false)
|
|
281
281
|
warnSpy.mockRestore()
|
|
282
282
|
})
|
|
283
283
|
|
|
284
|
-
test(
|
|
285
|
-
const el = document.createElement(
|
|
286
|
-
const warnSpy = vi.spyOn(console,
|
|
287
|
-
applyProp(el,
|
|
288
|
-
expect(el.hasAttribute(
|
|
284
|
+
test('blocks data: in src', () => {
|
|
285
|
+
const el = document.createElement('img')
|
|
286
|
+
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
|
287
|
+
applyProp(el, 'src', 'data:text/html,<script>alert(1)</script>')
|
|
288
|
+
expect(el.hasAttribute('src')).toBe(false)
|
|
289
289
|
warnSpy.mockRestore()
|
|
290
290
|
})
|
|
291
291
|
|
|
292
|
-
test(
|
|
293
|
-
const el = document.createElement(
|
|
294
|
-
applyProp(el,
|
|
295
|
-
expect(el.getAttribute(
|
|
292
|
+
test('allows safe URLs', () => {
|
|
293
|
+
const el = document.createElement('a')
|
|
294
|
+
applyProp(el, 'href', 'https://example.com')
|
|
295
|
+
expect(el.getAttribute('href')).toBe('https://example.com')
|
|
296
296
|
})
|
|
297
297
|
})
|
|
298
298
|
|
|
299
299
|
// ─── sanitizeHtml ────────────────────────────────────────────────────────────
|
|
300
300
|
|
|
301
|
-
describe(
|
|
302
|
-
test(
|
|
301
|
+
describe('sanitizeHtml', () => {
|
|
302
|
+
test('strips script tags', () => {
|
|
303
303
|
const result = sanitizeHtml('<div>hello</div><script>alert("xss")</script>')
|
|
304
|
-
expect(result).not.toContain(
|
|
305
|
-
expect(result).toContain(
|
|
304
|
+
expect(result).not.toContain('<script>')
|
|
305
|
+
expect(result).toContain('hello')
|
|
306
306
|
})
|
|
307
307
|
|
|
308
|
-
test(
|
|
308
|
+
test('strips event handler attributes', () => {
|
|
309
309
|
const result = sanitizeHtml('<div onclick="alert(1)">text</div>')
|
|
310
|
-
expect(result).not.toContain(
|
|
311
|
-
expect(result).toContain(
|
|
310
|
+
expect(result).not.toContain('onclick')
|
|
311
|
+
expect(result).toContain('text')
|
|
312
312
|
})
|
|
313
313
|
|
|
314
|
-
test(
|
|
314
|
+
test('strips javascript: URLs from href', () => {
|
|
315
315
|
const result = sanitizeHtml('<a href="javascript:alert(1)">click</a>')
|
|
316
|
-
expect(result).not.toContain(
|
|
316
|
+
expect(result).not.toContain('javascript:')
|
|
317
317
|
})
|
|
318
318
|
|
|
319
|
-
test(
|
|
320
|
-
const result = sanitizeHtml(
|
|
321
|
-
expect(result).toContain(
|
|
322
|
-
expect(result).toContain(
|
|
323
|
-
expect(result).toContain(
|
|
319
|
+
test('allows safe HTML tags', () => {
|
|
320
|
+
const result = sanitizeHtml('<b>bold</b> <em>italic</em> <p>paragraph</p>')
|
|
321
|
+
expect(result).toContain('<b>bold</b>')
|
|
322
|
+
expect(result).toContain('<em>italic</em>')
|
|
323
|
+
expect(result).toContain('<p>paragraph</p>')
|
|
324
324
|
})
|
|
325
325
|
|
|
326
|
-
test(
|
|
327
|
-
const custom = vi.fn((html: string) => html.replace(/<[^>]+>/g,
|
|
326
|
+
test('uses custom sanitizer when set', () => {
|
|
327
|
+
const custom = vi.fn((html: string) => html.replace(/<[^>]+>/g, ''))
|
|
328
328
|
setSanitizer(custom)
|
|
329
|
-
const result = sanitizeHtml(
|
|
330
|
-
expect(custom).toHaveBeenCalledWith(
|
|
331
|
-
expect(result).toBe(
|
|
329
|
+
const result = sanitizeHtml('<div>test</div>')
|
|
330
|
+
expect(custom).toHaveBeenCalledWith('<div>test</div>')
|
|
331
|
+
expect(result).toBe('test')
|
|
332
332
|
setSanitizer(null)
|
|
333
333
|
})
|
|
334
334
|
})
|
|
335
335
|
|
|
336
336
|
// ─── delegate.ts ─────────────────────────────────────────────────────────────
|
|
337
337
|
|
|
338
|
-
describe(
|
|
339
|
-
test(
|
|
340
|
-
expect(DELEGATED_EVENTS.has(
|
|
341
|
-
expect(DELEGATED_EVENTS.has(
|
|
342
|
-
expect(DELEGATED_EVENTS.has(
|
|
343
|
-
expect(DELEGATED_EVENTS.has(
|
|
338
|
+
describe('delegate', () => {
|
|
339
|
+
test('DELEGATED_EVENTS contains common bubbling events', () => {
|
|
340
|
+
expect(DELEGATED_EVENTS.has('click')).toBe(true)
|
|
341
|
+
expect(DELEGATED_EVENTS.has('input')).toBe(true)
|
|
342
|
+
expect(DELEGATED_EVENTS.has('keydown')).toBe(true)
|
|
343
|
+
expect(DELEGATED_EVENTS.has('submit')).toBe(true)
|
|
344
344
|
})
|
|
345
345
|
|
|
346
|
-
test(
|
|
347
|
-
expect(DELEGATED_EVENTS.has(
|
|
348
|
-
expect(DELEGATED_EVENTS.has(
|
|
349
|
-
expect(DELEGATED_EVENTS.has(
|
|
350
|
-
expect(DELEGATED_EVENTS.has(
|
|
351
|
-
expect(DELEGATED_EVENTS.has(
|
|
352
|
-
expect(DELEGATED_EVENTS.has(
|
|
346
|
+
test('DELEGATED_EVENTS does not contain non-bubbling events', () => {
|
|
347
|
+
expect(DELEGATED_EVENTS.has('focus')).toBe(false)
|
|
348
|
+
expect(DELEGATED_EVENTS.has('blur')).toBe(false)
|
|
349
|
+
expect(DELEGATED_EVENTS.has('mouseenter')).toBe(false)
|
|
350
|
+
expect(DELEGATED_EVENTS.has('mouseleave')).toBe(false)
|
|
351
|
+
expect(DELEGATED_EVENTS.has('load')).toBe(false)
|
|
352
|
+
expect(DELEGATED_EVENTS.has('scroll')).toBe(false)
|
|
353
353
|
})
|
|
354
354
|
|
|
355
|
-
test(
|
|
356
|
-
expect(delegatedPropName(
|
|
357
|
-
expect(delegatedPropName(
|
|
355
|
+
test('delegatedPropName returns __ev_{eventName}', () => {
|
|
356
|
+
expect(delegatedPropName('click')).toBe('__ev_click')
|
|
357
|
+
expect(delegatedPropName('input')).toBe('__ev_input')
|
|
358
358
|
})
|
|
359
359
|
|
|
360
|
-
test(
|
|
361
|
-
const container = document.createElement(
|
|
360
|
+
test('setupDelegation installs listeners and dispatches to expandos', () => {
|
|
361
|
+
const container = document.createElement('div')
|
|
362
362
|
document.body.appendChild(container)
|
|
363
363
|
setupDelegation(container)
|
|
364
364
|
|
|
365
|
-
const child = document.createElement(
|
|
365
|
+
const child = document.createElement('button')
|
|
366
366
|
container.appendChild(child)
|
|
367
367
|
|
|
368
368
|
const handler = vi.fn()
|
|
369
|
-
const prop = delegatedPropName(
|
|
369
|
+
const prop = delegatedPropName('click')
|
|
370
370
|
;(child as unknown as Record<string, unknown>)[prop] = handler
|
|
371
371
|
|
|
372
372
|
child.click()
|
|
@@ -375,18 +375,18 @@ describe("delegate", () => {
|
|
|
375
375
|
container.remove()
|
|
376
376
|
})
|
|
377
377
|
|
|
378
|
-
test(
|
|
379
|
-
const container = document.createElement(
|
|
378
|
+
test('setupDelegation is idempotent (safe to call twice)', () => {
|
|
379
|
+
const container = document.createElement('div')
|
|
380
380
|
document.body.appendChild(container)
|
|
381
381
|
// Should not throw when called twice
|
|
382
382
|
setupDelegation(container)
|
|
383
383
|
setupDelegation(container)
|
|
384
384
|
|
|
385
|
-
const child = document.createElement(
|
|
385
|
+
const child = document.createElement('span')
|
|
386
386
|
container.appendChild(child)
|
|
387
387
|
|
|
388
388
|
let callCount = 0
|
|
389
|
-
const prop = delegatedPropName(
|
|
389
|
+
const prop = delegatedPropName('click')
|
|
390
390
|
;(child as unknown as Record<string, unknown>)[prop] = () => {
|
|
391
391
|
callCount++
|
|
392
392
|
}
|
|
@@ -398,19 +398,19 @@ describe("delegate", () => {
|
|
|
398
398
|
container.remove()
|
|
399
399
|
})
|
|
400
400
|
|
|
401
|
-
test(
|
|
402
|
-
const container = document.createElement(
|
|
401
|
+
test('delegation walks up the DOM tree (ancestor handlers fire)', () => {
|
|
402
|
+
const container = document.createElement('div')
|
|
403
403
|
document.body.appendChild(container)
|
|
404
404
|
setupDelegation(container)
|
|
405
405
|
|
|
406
|
-
const parent = document.createElement(
|
|
407
|
-
const child = document.createElement(
|
|
406
|
+
const parent = document.createElement('div')
|
|
407
|
+
const child = document.createElement('span')
|
|
408
408
|
parent.appendChild(child)
|
|
409
409
|
container.appendChild(parent)
|
|
410
410
|
|
|
411
411
|
const parentHandler = vi.fn()
|
|
412
412
|
const childHandler = vi.fn()
|
|
413
|
-
const prop = delegatedPropName(
|
|
413
|
+
const prop = delegatedPropName('click')
|
|
414
414
|
;(parent as unknown as Record<string, unknown>)[prop] = parentHandler
|
|
415
415
|
;(child as unknown as Record<string, unknown>)[prop] = childHandler
|
|
416
416
|
|
|
@@ -421,19 +421,19 @@ describe("delegate", () => {
|
|
|
421
421
|
container.remove()
|
|
422
422
|
})
|
|
423
423
|
|
|
424
|
-
test(
|
|
425
|
-
const container = document.createElement(
|
|
424
|
+
test('delegation respects stopPropagation', () => {
|
|
425
|
+
const container = document.createElement('div')
|
|
426
426
|
document.body.appendChild(container)
|
|
427
427
|
setupDelegation(container)
|
|
428
428
|
|
|
429
|
-
const parent = document.createElement(
|
|
430
|
-
const child = document.createElement(
|
|
429
|
+
const parent = document.createElement('div')
|
|
430
|
+
const child = document.createElement('span')
|
|
431
431
|
parent.appendChild(child)
|
|
432
432
|
container.appendChild(parent)
|
|
433
433
|
|
|
434
434
|
const parentHandler = vi.fn()
|
|
435
435
|
const childHandler = vi.fn((e: Event) => e.stopPropagation())
|
|
436
|
-
const prop = delegatedPropName(
|
|
436
|
+
const prop = delegatedPropName('click')
|
|
437
437
|
;(parent as unknown as Record<string, unknown>)[prop] = parentHandler
|
|
438
438
|
;(child as unknown as Record<string, unknown>)[prop] = childHandler
|
|
439
439
|
|
|
@@ -444,16 +444,16 @@ describe("delegate", () => {
|
|
|
444
444
|
container.remove()
|
|
445
445
|
})
|
|
446
446
|
|
|
447
|
-
test(
|
|
448
|
-
const container = document.createElement(
|
|
447
|
+
test('delegation skips non-function expando values', () => {
|
|
448
|
+
const container = document.createElement('div')
|
|
449
449
|
document.body.appendChild(container)
|
|
450
450
|
setupDelegation(container)
|
|
451
451
|
|
|
452
|
-
const child = document.createElement(
|
|
452
|
+
const child = document.createElement('span')
|
|
453
453
|
container.appendChild(child)
|
|
454
454
|
|
|
455
|
-
const prop = delegatedPropName(
|
|
456
|
-
;(child as unknown as Record<string, unknown>)[prop] =
|
|
455
|
+
const prop = delegatedPropName('click')
|
|
456
|
+
;(child as unknown as Record<string, unknown>)[prop] = 'not-a-function'
|
|
457
457
|
|
|
458
458
|
// Should not throw
|
|
459
459
|
expect(() => child.click()).not.toThrow()
|