spyne 0.23.0 → 0.25.0
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/lib/spyne.esm.js +3 -3
- package/lib/spyne.esm.js.map +1 -1
- package/lib/spyne.umd.js +2 -2
- package/lib/spyne.umd.js.LICENSE.txt +2 -2
- package/package.json +1 -1
- package/src/spyne/channels/channel-fetch-class.js +14 -5
- package/src/spyne/spyne-app.js +7 -3
- package/src/spyne/spyne.js +5 -2
- package/src/spyne/utils/channel-fetch-util.js +239 -95
- package/src/spyne/utils/sanitize-data.js +320 -35
- package/src/spyne/utils/sanitize-html.js +59 -12
- package/src/spyne/utils/spyne-app-properties.js +35 -6
- package/src/spyne/utils/spyne-warn.js +26 -0
- package/src/spyne/views/dom-element-template.js +5 -0
- package/src/spyne/views/dom-element.js +22 -2
- package/src/spyne/views/view-stream-selector.js +52 -47
- package/src/tests/channels/channel-fetch.test.js +88 -1
- package/src/tests/index.test.js +5 -0
- package/src/tests/utils/channel-fetch-util.test.js +44 -2
- package/src/tests/utils/security.test.js +352 -8
- package/src/tests/views/dom-el-selectors.test.js +7 -7
- package/src/tests/views/view-stream-selector.test.js +178 -0
|
@@ -1,10 +1,354 @@
|
|
|
1
|
-
const {
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
1
|
+
const { expect } = require('chai')
|
|
2
|
+
import { SpyneApp, SpyneAppProperties } from '../../spyne/spyne'
|
|
3
|
+
import sanitizeData, { sanitizeAttribute, allowCustomElements } from '../../spyne/utils/sanitize-data'
|
|
4
|
+
import sanitizeHTML from '../../spyne/utils/sanitize-html'
|
|
5
|
+
import { DomElement } from '../../spyne/views/dom-element'
|
|
6
|
+
import { DomElementTemplate } from '../../spyne/views/dom-element-template'
|
|
7
|
+
|
|
8
|
+
describe('sanitization security', () => {
|
|
9
|
+
before(() => {
|
|
10
|
+
// Idempotent: configures the sanitizers if no other suite has already.
|
|
11
|
+
SpyneApp.init({})
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
describe('default posture', () => {
|
|
15
|
+
it('should default SpyneAppProperties.mode to app', () => {
|
|
16
|
+
expect(SpyneAppProperties.mode).to.equal('app')
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
it('should sanitize template HTML even when strict is false', () => {
|
|
20
|
+
const dirty = '<img src="x" onerror="alert(1)"><script>bad()</script><b>keep</b>'
|
|
21
|
+
const clean = String(sanitizeHTML(dirty))
|
|
22
|
+
|
|
23
|
+
expect(clean).to.not.include('onerror')
|
|
24
|
+
expect(clean).to.not.include('<script')
|
|
25
|
+
expect(clean).to.include('<b>keep</b>')
|
|
26
|
+
})
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
describe('sanitizeAttribute', () => {
|
|
30
|
+
it('should block event-handler attributes', () => {
|
|
31
|
+
expect(sanitizeAttribute('onclick', 'evil()').allowed).to.be.false
|
|
32
|
+
expect(sanitizeAttribute('ONERROR', 'evil()').allowed).to.be.false
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
it('should block javascript: URIs on URL attributes', () => {
|
|
36
|
+
expect(sanitizeAttribute('href', 'javascript:alert(1)').allowed).to.be.false
|
|
37
|
+
expect(sanitizeAttribute('src', 'JaVaScRiPt:alert(1)').allowed).to.be.false
|
|
38
|
+
expect(sanitizeAttribute('formaction', 'vbscript:x').allowed).to.be.false
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
it('should block whitespace-obfuscated schemes', () => {
|
|
42
|
+
expect(sanitizeAttribute('href', 'jav\tascript:alert(1)').allowed).to.be.false
|
|
43
|
+
expect(sanitizeAttribute('href', ' javascript:alert(1)').allowed).to.be.false
|
|
44
|
+
expect(sanitizeAttribute('href', 'java\nscript:alert(1)').allowed).to.be.false
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
it('should allow safe and relative URIs', () => {
|
|
48
|
+
expect(sanitizeAttribute('href', 'https://example.com').allowed).to.be.true
|
|
49
|
+
expect(sanitizeAttribute('href', '/page/one').allowed).to.be.true
|
|
50
|
+
expect(sanitizeAttribute('href', '#section').allowed).to.be.true
|
|
51
|
+
expect(sanitizeAttribute('href', 'mailto:a@b.com').allowed).to.be.true
|
|
52
|
+
expect(sanitizeAttribute('src', 'data:image/png;base64,AA==').allowed).to.be.true
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
it('should block data URIs that are not images', () => {
|
|
56
|
+
expect(sanitizeAttribute('src', 'data:text/html,<script>x</script>').allowed).to.be.false
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
it('should sanitize srcdoc as an HTML document', () => {
|
|
60
|
+
const result = sanitizeAttribute('srcdoc', '<script>bad()</script><p>ok</p>')
|
|
61
|
+
|
|
62
|
+
expect(result.allowed).to.be.true
|
|
63
|
+
expect(result.value).to.not.include('<script')
|
|
64
|
+
expect(result.value).to.include('<p>ok</p>')
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
it('should pass non-URL attributes through untouched', () => {
|
|
68
|
+
const result = sanitizeAttribute('id', 'my-id')
|
|
69
|
+
|
|
70
|
+
expect(result.allowed).to.be.true
|
|
71
|
+
expect(result.value).to.equal('my-id')
|
|
72
|
+
})
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
describe('sanitizeData modes', () => {
|
|
76
|
+
it('should strip scripts and iframes in app mode', () => {
|
|
77
|
+
const clean = sanitizeData('<script>bad()</script><iframe src="https://x.com"></iframe><b>keep</b>', { mode: 'app' })
|
|
78
|
+
|
|
79
|
+
expect(clean).to.not.include('<script')
|
|
80
|
+
expect(clean).to.not.include('<iframe')
|
|
81
|
+
expect(clean).to.include('<b>keep</b>')
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
it('should sanitize nested object and array values', () => {
|
|
85
|
+
const data = {
|
|
86
|
+
title: '<script>bad()</script>ok',
|
|
87
|
+
items: ['<b>fine</b>', { deep: '<script>x</script>deep-ok' }]
|
|
88
|
+
}
|
|
89
|
+
const clean = sanitizeData(data, { mode: 'app' })
|
|
90
|
+
|
|
91
|
+
expect(clean.title).to.equal('ok')
|
|
92
|
+
expect(clean.items[0]).to.equal('<b>fine</b>')
|
|
93
|
+
expect(clean.items[1].deep).to.equal('deep-ok')
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
it('should keep iframes in richtext mode with a forced sandbox', () => {
|
|
97
|
+
const clean = sanitizeData('<iframe src="https://example.com/embed"></iframe>', { mode: 'richtext' })
|
|
98
|
+
|
|
99
|
+
expect(clean).to.include('<iframe')
|
|
100
|
+
expect(clean).to.include('sandbox=')
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
it('should remove non-https iframe src in richtext mode', () => {
|
|
104
|
+
const clean = sanitizeData('<iframe src="http://example.com/embed"></iframe>', { mode: 'richtext' })
|
|
105
|
+
|
|
106
|
+
expect(clean).to.include('<iframe')
|
|
107
|
+
expect(clean).to.not.include('src=')
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
it('should drop javascript: hrefs in richtext content', () => {
|
|
111
|
+
const clean = sanitizeData('<a href="javascript:alert(1)">x</a>', { mode: 'richtext' })
|
|
112
|
+
|
|
113
|
+
expect(clean).to.not.include('javascript:')
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
it('should return data untouched with per-call disableSanitize', () => {
|
|
117
|
+
const raw = '<script>kept-by-request()</script>'
|
|
118
|
+
|
|
119
|
+
expect(sanitizeData(raw, { disableSanitize: true })).to.equal(raw)
|
|
120
|
+
})
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
describe('iframe policy', () => {
|
|
124
|
+
it('should allow iframes in app mode with iframes.allow', () => {
|
|
125
|
+
const clean = sanitizeData('<iframe src="https://example.com/embed"></iframe>', {
|
|
126
|
+
mode: 'app',
|
|
127
|
+
iframes: { allow: true }
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
expect(clean).to.include('<iframe')
|
|
131
|
+
expect(clean).to.include('sandbox=')
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
it('should strip iframes from richtext mode with iframes.allow false', () => {
|
|
135
|
+
const clean = sanitizeData('<iframe src="https://example.com/embed"></iframe><b>keep</b>', {
|
|
136
|
+
mode: 'richtext',
|
|
137
|
+
iframes: { allow: false }
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
expect(clean).to.not.include('<iframe')
|
|
141
|
+
expect(clean).to.include('<b>keep</b>')
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
it('should enforce the origin allowlist on iframe src', () => {
|
|
145
|
+
const opts = {
|
|
146
|
+
mode: 'richtext',
|
|
147
|
+
iframes: { allowedOrigins: ['https://www.youtube.com'] }
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const allowed = sanitizeData('<iframe src="https://www.youtube.com/embed/abc"></iframe>', opts)
|
|
151
|
+
const blocked = sanitizeData('<iframe src="https://evil.example.com/embed"></iframe>', opts)
|
|
152
|
+
|
|
153
|
+
expect(allowed).to.include('src="https://www.youtube.com/embed/abc"')
|
|
154
|
+
expect(blocked).to.include('<iframe')
|
|
155
|
+
expect(blocked).to.not.include('src=')
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
it('should respect a custom sandbox value and sandbox false', () => {
|
|
159
|
+
const custom = sanitizeData('<iframe src="https://example.com/e"></iframe>', {
|
|
160
|
+
mode: 'richtext',
|
|
161
|
+
iframes: { sandbox: 'allow-scripts' }
|
|
162
|
+
})
|
|
163
|
+
const disabled = sanitizeData('<iframe src="https://example.com/e"></iframe>', {
|
|
164
|
+
mode: 'richtext',
|
|
165
|
+
iframes: { sandbox: false }
|
|
166
|
+
})
|
|
167
|
+
|
|
168
|
+
expect(custom).to.include('sandbox="allow-scripts"')
|
|
169
|
+
expect(disabled).to.not.include('sandbox')
|
|
170
|
+
})
|
|
171
|
+
|
|
172
|
+
it('should keep an author-specified sandbox attribute', () => {
|
|
173
|
+
const clean = sanitizeData('<iframe src="https://example.com/e" sandbox="allow-forms"></iframe>', {
|
|
174
|
+
mode: 'richtext'
|
|
175
|
+
})
|
|
176
|
+
|
|
177
|
+
expect(clean).to.include('sandbox="allow-forms"')
|
|
178
|
+
})
|
|
179
|
+
|
|
180
|
+
it('should apply iframe policy on the DomElement attribute channel', () => {
|
|
181
|
+
const el = new DomElement({
|
|
182
|
+
tagName: 'iframe',
|
|
183
|
+
attributes: { src: 'https://example.com/embed' }
|
|
184
|
+
}).render()
|
|
185
|
+
|
|
186
|
+
expect(el.getAttribute('src')).to.equal('https://example.com/embed')
|
|
187
|
+
expect(el.getAttribute('sandbox')).to.be.a('string')
|
|
188
|
+
})
|
|
189
|
+
|
|
190
|
+
it('should block non-https iframe src on the DomElement attribute channel', () => {
|
|
191
|
+
const el = new DomElement({
|
|
192
|
+
tagName: 'iframe',
|
|
193
|
+
attributes: { src: 'http://example.com/embed' }
|
|
194
|
+
}).render()
|
|
195
|
+
|
|
196
|
+
expect(el.hasAttribute('src')).to.be.false
|
|
197
|
+
})
|
|
198
|
+
|
|
199
|
+
it('should allow same-origin relative iframe src', () => {
|
|
200
|
+
const clean = sanitizeData('<iframe src="/static/iframes/tool/index.html"></iframe>', {
|
|
201
|
+
mode: 'richtext'
|
|
202
|
+
})
|
|
203
|
+
|
|
204
|
+
expect(clean).to.include('src="/static/iframes/tool/index.html"')
|
|
205
|
+
})
|
|
206
|
+
|
|
207
|
+
it('should allow same-origin absolute iframe src regardless of scheme', () => {
|
|
208
|
+
const sameOriginUrl = `${window.location.origin}/static/iframes/tool/index.html`
|
|
209
|
+
const clean = sanitizeData(`<iframe src="${sameOriginUrl}"></iframe>`, {
|
|
210
|
+
mode: 'richtext'
|
|
211
|
+
})
|
|
212
|
+
|
|
213
|
+
expect(clean).to.include(`src="${sameOriginUrl}"`)
|
|
214
|
+
})
|
|
215
|
+
|
|
216
|
+
it('should allow http on localhost hosts as a secure context', () => {
|
|
217
|
+
const clean = sanitizeData('<iframe src="http://localhost:8123/dev-tool.html"></iframe>', {
|
|
218
|
+
mode: 'richtext'
|
|
219
|
+
})
|
|
220
|
+
|
|
221
|
+
expect(clean).to.include('src="http://localhost:8123/dev-tool.html"')
|
|
222
|
+
})
|
|
223
|
+
|
|
224
|
+
it('should allow same-origin src even when an origin allowlist is set', () => {
|
|
225
|
+
const clean = sanitizeData('<iframe src="/local/tool.html"></iframe>', {
|
|
226
|
+
mode: 'richtext',
|
|
227
|
+
iframes: { allowedOrigins: ['https://www.youtube.com'] }
|
|
228
|
+
})
|
|
229
|
+
|
|
230
|
+
expect(clean).to.include('src="/local/tool.html"')
|
|
231
|
+
})
|
|
232
|
+
})
|
|
233
|
+
|
|
234
|
+
describe('DomElement attribute guard', () => {
|
|
235
|
+
it('should drop unsafe attribute values and keep safe ones', () => {
|
|
236
|
+
const el = new DomElement({
|
|
237
|
+
tagName: 'a',
|
|
238
|
+
attributes: {
|
|
239
|
+
href: 'javascript:alert(1)',
|
|
240
|
+
onclick: 'evil()',
|
|
241
|
+
id: 'safe-id',
|
|
242
|
+
title: 'safe title'
|
|
243
|
+
}
|
|
244
|
+
}).render()
|
|
245
|
+
|
|
246
|
+
expect(el.hasAttribute('href')).to.be.false
|
|
247
|
+
expect(el.hasAttribute('onclick')).to.be.false
|
|
248
|
+
expect(el.getAttribute('id')).to.equal('safe-id')
|
|
249
|
+
expect(el.getAttribute('title')).to.equal('safe title')
|
|
250
|
+
})
|
|
251
|
+
|
|
252
|
+
it('should keep safe URL attributes', () => {
|
|
253
|
+
const el = new DomElement({
|
|
254
|
+
tagName: 'a',
|
|
255
|
+
attributes: { href: 'https://example.com/page' }
|
|
256
|
+
}).render()
|
|
257
|
+
|
|
258
|
+
expect(el.getAttribute('href')).to.equal('https://example.com/page')
|
|
259
|
+
})
|
|
260
|
+
|
|
261
|
+
it('should apply dataset values untouched', () => {
|
|
262
|
+
const el = new DomElement({
|
|
263
|
+
tagName: 'div',
|
|
264
|
+
attributes: { dataset: { pageId: 'page-1' } }
|
|
265
|
+
}).render()
|
|
266
|
+
|
|
267
|
+
expect(el.dataset.pageId).to.equal('page-1')
|
|
268
|
+
})
|
|
269
|
+
})
|
|
270
|
+
|
|
271
|
+
describe('CMS wrappers and custom elements', () => {
|
|
272
|
+
const cmsMarkup = '<spyne-cms-item data-cms-id="post.title" data-cms-key="title">' +
|
|
273
|
+
'<spyne-cms-item-hitbox></spyne-cms-item-hitbox>' +
|
|
274
|
+
'<spyne-cms-item-text>Hello</spyne-cms-item-text>' +
|
|
275
|
+
'</spyne-cms-item>'
|
|
276
|
+
|
|
277
|
+
it('should keep spyne-* CMS wrappers and data-cms attributes in app mode', () => {
|
|
278
|
+
const clean = sanitizeData(cmsMarkup, { mode: 'app' })
|
|
279
|
+
|
|
280
|
+
expect(clean).to.include('<spyne-cms-item')
|
|
281
|
+
expect(clean).to.include('data-cms-key="title"')
|
|
282
|
+
expect(clean).to.include('<spyne-cms-item-hitbox')
|
|
283
|
+
expect(clean).to.include('<spyne-cms-item-text>Hello</spyne-cms-item-text>')
|
|
284
|
+
})
|
|
285
|
+
|
|
286
|
+
it('should keep spyne-* CMS wrappers in richtext mode', () => {
|
|
287
|
+
const clean = sanitizeData(cmsMarkup, { mode: 'richtext' })
|
|
288
|
+
|
|
289
|
+
expect(clean).to.include('<spyne-cms-item')
|
|
290
|
+
expect(clean).to.include('data-cms-id="post.title"')
|
|
291
|
+
})
|
|
292
|
+
|
|
293
|
+
it('should keep spyne-* CMS wrappers at the template layer', () => {
|
|
294
|
+
const clean = String(sanitizeHTML(cmsMarkup))
|
|
295
|
+
|
|
296
|
+
expect(clean).to.include('<spyne-cms-item')
|
|
297
|
+
expect(clean).to.include('<spyne-cms-item-text>Hello</spyne-cms-item-text>')
|
|
298
|
+
})
|
|
299
|
+
|
|
300
|
+
it('should still sanitize content inside CMS wrappers', () => {
|
|
301
|
+
const dirty = '<spyne-cms-item data-cms-key="body">' +
|
|
302
|
+
'<spyne-cms-item-text><script>bad()</script><b>ok</b></spyne-cms-item-text>' +
|
|
303
|
+
'</spyne-cms-item>'
|
|
304
|
+
const clean = sanitizeData(dirty, { mode: 'app' })
|
|
305
|
+
|
|
306
|
+
expect(clean).to.include('<spyne-cms-item')
|
|
307
|
+
expect(clean).to.not.include('<script')
|
|
308
|
+
expect(clean).to.include('<b>ok</b>')
|
|
309
|
+
})
|
|
310
|
+
|
|
311
|
+
it('should not allow event-handler attributes on CMS wrappers', () => {
|
|
312
|
+
const dirty = '<spyne-cms-item onclick="evil()" data-cms-key="x">y</spyne-cms-item>'
|
|
313
|
+
const clean = sanitizeData(dirty, { mode: 'app' })
|
|
314
|
+
|
|
315
|
+
expect(clean).to.include('<spyne-cms-item')
|
|
316
|
+
expect(clean).to.not.include('onclick')
|
|
317
|
+
})
|
|
318
|
+
|
|
319
|
+
it('should admit elements registered via allowCustomElements', () => {
|
|
320
|
+
allowCustomElements(['my-widget'])
|
|
321
|
+
const clean = sanitizeData('<my-widget data-config="a">w</my-widget>', { mode: 'app' })
|
|
322
|
+
|
|
323
|
+
expect(clean).to.include('<my-widget')
|
|
324
|
+
expect(clean).to.include('data-config="a"')
|
|
325
|
+
})
|
|
326
|
+
|
|
327
|
+
it('should reject invalid custom element names', () => {
|
|
328
|
+
allowCustomElements(['script', 'iframe', 'div'])
|
|
329
|
+
const clean = sanitizeData('<script>bad()</script>', { mode: 'app' })
|
|
330
|
+
|
|
331
|
+
expect(clean).to.not.include('<script')
|
|
332
|
+
})
|
|
333
|
+
})
|
|
334
|
+
|
|
335
|
+
describe('DomElementTemplate output', () => {
|
|
336
|
+
it('should sanitize renderToString output', () => {
|
|
337
|
+
const tmpl = new DomElementTemplate('<b>{{word}}</b><script>bad()</script>', { word: 'hello' })
|
|
338
|
+
const html = tmpl.renderToString()
|
|
339
|
+
|
|
340
|
+
expect(html).to.not.include('<script')
|
|
341
|
+
expect(html).to.include('<b>hello</b>')
|
|
342
|
+
})
|
|
343
|
+
|
|
344
|
+
it('should sanitize renderDocFrag output', () => {
|
|
345
|
+
const tmpl = new DomElementTemplate('<p>{{word}}</p><img src="x" onerror="alert(1)">', { word: 'hi' })
|
|
346
|
+
const frag = tmpl.renderDocFrag()
|
|
347
|
+
const div = document.createElement('div')
|
|
348
|
+
div.appendChild(frag)
|
|
349
|
+
|
|
350
|
+
expect(div.innerHTML).to.not.include('onerror')
|
|
351
|
+
expect(div.innerHTML).to.include('<p>hi</p>')
|
|
352
|
+
})
|
|
9
353
|
})
|
|
10
354
|
})
|
|
@@ -41,14 +41,14 @@ describe('Dom Item Selector', () => {
|
|
|
41
41
|
const el = document.querySelector('ul#my-list')
|
|
42
42
|
const el$ = ViewStreamSelector('ul#my-list')
|
|
43
43
|
const liList = el$('li')
|
|
44
|
-
expect(liList.
|
|
44
|
+
expect(liList.els.length).to.eq(5)
|
|
45
45
|
})
|
|
46
46
|
it('should add class to li', () => {
|
|
47
47
|
const el = document.querySelector('ul#my-list')
|
|
48
48
|
const el$ = ViewStreamSelector('ul#my-list')
|
|
49
49
|
const liList = el$('li')
|
|
50
50
|
liList.addClass('foo')
|
|
51
|
-
const hasFooClassBool = liList.
|
|
51
|
+
const hasFooClassBool = liList.els[0].classList.contains('foo')
|
|
52
52
|
expect(hasFooClassBool).to.eq(true)
|
|
53
53
|
})
|
|
54
54
|
|
|
@@ -57,7 +57,7 @@ describe('Dom Item Selector', () => {
|
|
|
57
57
|
const el$ = ViewStreamSelector('ul#my-list')
|
|
58
58
|
const liList = el$('li')
|
|
59
59
|
liList.removeClass('has-svg')
|
|
60
|
-
const hasSvgClassBool = liList.
|
|
60
|
+
const hasSvgClassBool = liList.els[0].classList.contains('has-svg')
|
|
61
61
|
expect(hasSvgClassBool).to.eq(false)
|
|
62
62
|
})
|
|
63
63
|
|
|
@@ -66,8 +66,8 @@ describe('Dom Item Selector', () => {
|
|
|
66
66
|
const el$ = ViewStreamSelector('ul#my-list')
|
|
67
67
|
const liList = el$('li')
|
|
68
68
|
liList.setClass('foo bar')
|
|
69
|
-
// console.log('liList ',liList.
|
|
70
|
-
const isFooBarClassBool = liList.
|
|
69
|
+
// console.log('liList ',liList.els[0].className)
|
|
70
|
+
const isFooBarClassBool = liList.els[0].className === 'foo bar'
|
|
71
71
|
expect(isFooBarClassBool).to.eq(true)
|
|
72
72
|
})
|
|
73
73
|
|
|
@@ -76,7 +76,7 @@ describe('Dom Item Selector', () => {
|
|
|
76
76
|
const el$ = ViewStreamSelector('ul#my-list')
|
|
77
77
|
const liList = el$('li')
|
|
78
78
|
liList.inlineCss = 'background:orange;'
|
|
79
|
-
const backgroundSetBool = liList.
|
|
79
|
+
const backgroundSetBool = liList.els[0].style.getPropertyValue('background') === 'orange'
|
|
80
80
|
expect(backgroundSetBool).to.eq(true)
|
|
81
81
|
})
|
|
82
82
|
|
|
@@ -85,7 +85,7 @@ describe('Dom Item Selector', () => {
|
|
|
85
85
|
const el$ = ViewStreamSelector('ul#my-list')
|
|
86
86
|
const liList = el$('li')
|
|
87
87
|
liList.toggleClass('foo', true)
|
|
88
|
-
const hasFooClassBool = liList.
|
|
88
|
+
const hasFooClassBool = liList.els[0].classList.contains('foo')
|
|
89
89
|
expect(hasFooClassBool).to.eq(true)
|
|
90
90
|
})
|
|
91
91
|
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
const { expect } = require('chai')
|
|
2
|
+
import { ViewStreamSelector } from '../../spyne/views/view-stream-selector'
|
|
3
|
+
|
|
4
|
+
describe('ViewStreamSelector', () => {
|
|
5
|
+
let container
|
|
6
|
+
|
|
7
|
+
const el$ = (sel) => ViewStreamSelector(container, sel)
|
|
8
|
+
|
|
9
|
+
beforeEach(() => {
|
|
10
|
+
container = document.createElement('div')
|
|
11
|
+
container.innerHTML =
|
|
12
|
+
'<ul>' +
|
|
13
|
+
'<li class="item">A</li>' +
|
|
14
|
+
'<li class="item">A</li>' +
|
|
15
|
+
'<li class="item special">C</li>' +
|
|
16
|
+
'</ul>' +
|
|
17
|
+
'<p class="solo">only</p>'
|
|
18
|
+
document.body.appendChild(container)
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
afterEach(() => {
|
|
22
|
+
container.remove()
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
describe('el (always a single element)', () => {
|
|
26
|
+
it('should return the element for a single match', () => {
|
|
27
|
+
const el = el$('.solo').el
|
|
28
|
+
expect(el.nodeType).to.equal(1)
|
|
29
|
+
expect(el.textContent).to.equal('only')
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
it('should return the first element for multiple matches', () => {
|
|
33
|
+
const el = el$('.item').el
|
|
34
|
+
expect(el.nodeType).to.equal(1)
|
|
35
|
+
expect(el.textContent).to.equal('A')
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
it('should return null for no matches, mirroring querySelector', () => {
|
|
39
|
+
expect(el$('.nope').el).to.equal(null)
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
it('should support direct element methods on multi-match selectors', () => {
|
|
43
|
+
el$('.item').el.setAttribute('data-first', 'true')
|
|
44
|
+
const items = container.querySelectorAll('.item')
|
|
45
|
+
expect(items[0].dataset.first).to.equal('true')
|
|
46
|
+
expect(items[1].dataset.first).to.be.undefined
|
|
47
|
+
})
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
describe('els (always an Array)', () => {
|
|
51
|
+
it('should return an Array for multiple matches', () => {
|
|
52
|
+
const els = el$('.item').els
|
|
53
|
+
expect(Array.isArray(els)).to.be.true
|
|
54
|
+
expect(els).to.have.length(3)
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
it('should return a single-item Array for one match', () => {
|
|
58
|
+
const els = el$('.solo').els
|
|
59
|
+
expect(Array.isArray(els)).to.be.true
|
|
60
|
+
expect(els).to.have.length(1)
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
it('should return an empty Array for no matches', () => {
|
|
64
|
+
const els = el$('.nope').els
|
|
65
|
+
expect(Array.isArray(els)).to.be.true
|
|
66
|
+
expect(els).to.have.length(0)
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
it('should support Array methods directly', () => {
|
|
70
|
+
const texts = el$('.item').els.map(el => el.textContent)
|
|
71
|
+
expect(texts).to.deep.equal(['A', 'A', 'C'])
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
it('should keep arr as an equivalent legacy alias', () => {
|
|
75
|
+
expect(el$('.item').arr).to.deep.equal(el$('.item').els)
|
|
76
|
+
expect(Array.isArray(el$('.item').arr)).to.be.true
|
|
77
|
+
})
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
describe('length and len', () => {
|
|
81
|
+
it('should return the match count from length', () => {
|
|
82
|
+
expect(el$('.item').length).to.equal(3)
|
|
83
|
+
expect(el$('.solo').length).to.equal(1)
|
|
84
|
+
expect(el$('.nope').length).to.equal(0)
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
it('should keep len in agreement with length', () => {
|
|
88
|
+
expect(el$('.item').len).to.equal(el$('.item').length)
|
|
89
|
+
})
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
describe('chainability', () => {
|
|
93
|
+
it('should chain class mutators', () => {
|
|
94
|
+
el$('.item').addClass('x').toggleClass('y', true).removeClass('x')
|
|
95
|
+
|
|
96
|
+
const item = container.querySelector('.item')
|
|
97
|
+
expect(item.classList.contains('y')).to.be.true
|
|
98
|
+
expect(item.classList.contains('x')).to.be.false
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
it('should return the selector from mutators', () => {
|
|
102
|
+
const s = el$('.item')
|
|
103
|
+
expect(s.addClass('z')).to.equal(s)
|
|
104
|
+
expect(s.toggleClass('z')).to.equal(s)
|
|
105
|
+
expect(s.setClass('q')).to.equal(s)
|
|
106
|
+
expect(s.setActiveItem('active', '.special')).to.equal(s)
|
|
107
|
+
})
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
describe('appendChild', () => {
|
|
111
|
+
it('should append to the first element on a multi-match selector', () => {
|
|
112
|
+
const span = document.createElement('span')
|
|
113
|
+
span.className = 'appended'
|
|
114
|
+
|
|
115
|
+
el$('.item').appendChild(span)
|
|
116
|
+
|
|
117
|
+
const items = container.querySelectorAll('.item')
|
|
118
|
+
expect(items[0].querySelector('.appended')).to.not.be.null
|
|
119
|
+
expect(items[1].querySelector('.appended')).to.be.null
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
it('should warn and not throw on a non-matching selector', () => {
|
|
123
|
+
expect(() => el$('.nope').appendChild(document.createElement('b'))).to.not.throw()
|
|
124
|
+
})
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
describe('setActiveItem', () => {
|
|
128
|
+
it('should activate only the exact matching node', () => {
|
|
129
|
+
el$('.item').setActiveItem('active', '.special')
|
|
130
|
+
|
|
131
|
+
const items = container.querySelectorAll('.item')
|
|
132
|
+
expect(items[0].classList.contains('active')).to.be.false
|
|
133
|
+
expect(items[1].classList.contains('active')).to.be.false
|
|
134
|
+
expect(items[2].classList.contains('active')).to.be.true
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
it('should use identity, not deep equality, for duplicate siblings', () => {
|
|
138
|
+
const items = container.querySelectorAll('.item')
|
|
139
|
+
|
|
140
|
+
// items[0] and items[1] are identical markup; only the exact node activates
|
|
141
|
+
el$('.item').setActiveItem('active', items[1])
|
|
142
|
+
|
|
143
|
+
expect(items[0].classList.contains('active')).to.be.false
|
|
144
|
+
expect(items[1].classList.contains('active')).to.be.true
|
|
145
|
+
})
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
describe('elLegacy (undocumented pre-0.24 el contract)', () => {
|
|
149
|
+
it('should return a single element for a single match', () => {
|
|
150
|
+
expect(el$('.solo').elLegacy.nodeType).to.equal(1)
|
|
151
|
+
})
|
|
152
|
+
|
|
153
|
+
it('should return a NodeList for multiple matches', () => {
|
|
154
|
+
const el = el$('.item').elLegacy
|
|
155
|
+
expect(el.length).to.equal(3)
|
|
156
|
+
expect(el.nodeType).to.be.undefined
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
it('should return an empty list for no matches', () => {
|
|
160
|
+
expect(el$('.nope').elLegacy.length).to.equal(0)
|
|
161
|
+
})
|
|
162
|
+
})
|
|
163
|
+
|
|
164
|
+
describe('removed legacy members', () => {
|
|
165
|
+
it('should no longer define setActiveItem2, element, exist, nodeList, or getNodeListArray', () => {
|
|
166
|
+
const s = el$('.item')
|
|
167
|
+
expect(s.setActiveItem2).to.be.undefined
|
|
168
|
+
expect(s.element).to.be.undefined
|
|
169
|
+
expect(s.exist).to.be.undefined
|
|
170
|
+
expect(s.nodeList).to.be.undefined
|
|
171
|
+
expect(s.getNodeListArray).to.be.undefined
|
|
172
|
+
})
|
|
173
|
+
|
|
174
|
+
it('should keep unmount as a callable no-op for legacy apps', () => {
|
|
175
|
+
expect(() => el$('.item').unmount()).to.not.throw()
|
|
176
|
+
})
|
|
177
|
+
})
|
|
178
|
+
})
|