spyne 0.24.0 → 0.26.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.LICENSE.txt +2 -2
- 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-payload-class.js +112 -17
- package/src/spyne/channels/channel.js +13 -0
- package/src/spyne/channels/spyne-channel-window.js +95 -3
- package/src/spyne/spyne-app.js +5 -2
- package/src/spyne/spyne-plugins.js +17 -0
- package/src/spyne/spyne.js +11 -3
- package/src/spyne/utils/channel-fetch-util.js +3 -2
- package/src/spyne/utils/channel-payload-filter.js +26 -22
- package/src/spyne/utils/safe-clone.js +128 -2
- package/src/spyne/utils/sanitize-data.js +244 -23
- package/src/spyne/utils/sanitize-html.js +17 -40
- package/src/spyne/utils/spyne-app-properties.js +30 -0
- package/src/spyne/utils/spyne-warn.js +26 -0
- package/src/spyne/views/dom-element-template.js +93 -0
- package/src/spyne/views/dom-element.js +4 -3
- package/src/spyne/views/view-stream-selector.js +52 -47
- package/src/tests/channels/channel-payload-filter.test.js +26 -0
- package/src/tests/channels/channel-window-custom-events.test.js +130 -0
- package/src/tests/index.test.js +5 -0
- package/src/tests/utils/safe-clone.test.js +193 -0
- package/src/tests/utils/security.test.js +153 -1
- package/src/tests/views/dom-el-selectors.test.js +7 -7
- package/src/tests/views/view-stream-selector.test.js +178 -0
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
import { safeClone, safeCloneDeep, safeAugment, safeFilter, safeMap, safeReject } from '../../spyne/utils/safe-clone'
|
|
2
|
+
import { SpyneAppProperties } from '../../spyne/utils/spyne-app-properties'
|
|
3
|
+
import { ChannelPayload } from '../../spyne/channels/channel-payload-class'
|
|
4
|
+
|
|
5
|
+
const PROXY_NAME = 'safeCloneTestProxy'
|
|
6
|
+
|
|
7
|
+
// stub reviver: wraps the cloned target in a Proxy that answers a marker key
|
|
8
|
+
const stubReviver = (target, proxyProps) => new Proxy(target, {
|
|
9
|
+
get(t, key) {
|
|
10
|
+
if (key === '__test__revived') {
|
|
11
|
+
return true
|
|
12
|
+
}
|
|
13
|
+
return t[key]
|
|
14
|
+
}
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
const makeProxyMarkedObj = (extraProps = {}) => ({
|
|
18
|
+
__proxy__proxyName: PROXY_NAME,
|
|
19
|
+
__proxy__isProxy: true,
|
|
20
|
+
__proxy__props: {},
|
|
21
|
+
headline: 'A Test Headline',
|
|
22
|
+
...extraProps
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
describe('safeClone proxy revival', () => {
|
|
26
|
+
before(() => {
|
|
27
|
+
SpyneAppProperties.registerProxyReviver(PROXY_NAME, stubReviver)
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
describe('safeClone (existing behavior)', () => {
|
|
31
|
+
it('revives a root-level proxy object', () => {
|
|
32
|
+
const revived = safeClone(makeProxyMarkedObj())
|
|
33
|
+
expect(revived.__test__revived).to.be.true
|
|
34
|
+
expect(revived.headline).to.equal('A Test Headline')
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
it('deep-copies a plain object without mutating the source', () => {
|
|
38
|
+
const src = { a: { b: 1 } }
|
|
39
|
+
const copy = safeClone(src)
|
|
40
|
+
copy.a.b = 2
|
|
41
|
+
expect(src.a.b).to.equal(1)
|
|
42
|
+
})
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
describe('safeCloneDeep', () => {
|
|
46
|
+
it('revives a root-level proxy object', () => {
|
|
47
|
+
const revived = safeCloneDeep(makeProxyMarkedObj())
|
|
48
|
+
expect(revived.__test__revived).to.be.true
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
it('revives a proxy nested inside a plain wrapper', () => {
|
|
52
|
+
const wrapper = { pageData: { story: makeProxyMarkedObj() } }
|
|
53
|
+
const copy = safeCloneDeep(wrapper)
|
|
54
|
+
expect(copy.pageData.story.__test__revived).to.be.true
|
|
55
|
+
expect(copy.pageData.story.headline).to.equal('A Test Headline')
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
it('revives proxies inside arrays', () => {
|
|
59
|
+
const arr = [makeProxyMarkedObj(), { plain: true }]
|
|
60
|
+
const copy = safeCloneDeep(arr)
|
|
61
|
+
expect(copy[0].__test__revived).to.be.true
|
|
62
|
+
expect(copy[1].plain).to.be.true
|
|
63
|
+
expect(copy).to.not.equal(arr)
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
it('passes functions through by reference', () => {
|
|
67
|
+
const fn = () => 'curried'
|
|
68
|
+
const copy = safeCloneDeep({ curriedData: fn })
|
|
69
|
+
expect(copy.curriedData).to.equal(fn)
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
it('does not infinitely recurse on cycles', () => {
|
|
73
|
+
const obj = { name: 'cyclical' }
|
|
74
|
+
obj.self = obj
|
|
75
|
+
const copy = safeCloneDeep(obj)
|
|
76
|
+
expect(copy.name).to.equal('cyclical')
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
it('returns primitives as-is', () => {
|
|
80
|
+
expect(safeCloneDeep('str')).to.equal('str')
|
|
81
|
+
expect(safeCloneDeep(5)).to.equal(5)
|
|
82
|
+
expect(safeCloneDeep(null)).to.equal(null)
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
it('is reachable through safeClone(o, true)', () => {
|
|
86
|
+
const wrapper = { story: makeProxyMarkedObj() }
|
|
87
|
+
const copy = safeClone(wrapper, true)
|
|
88
|
+
expect(copy.story.__test__revived).to.be.true
|
|
89
|
+
})
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
describe('safeFilter / safeReject / safeMap', () => {
|
|
93
|
+
const makeProxyMarkedArr = () => {
|
|
94
|
+
const arr = [
|
|
95
|
+
{ menu_label: 'a', headline: 'A' },
|
|
96
|
+
{ menu_label: 'b', headline: 'B' },
|
|
97
|
+
{ menu_label: 'c', headline: 'C' }
|
|
98
|
+
]
|
|
99
|
+
arr.__proxy__proxyName = PROXY_NAME
|
|
100
|
+
arr.__proxy__isProxy = true
|
|
101
|
+
arr.__proxy__props = {}
|
|
102
|
+
return arr
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
it('safeFilter keeps element identity and re-wraps the array', () => {
|
|
106
|
+
const src = makeProxyMarkedArr()
|
|
107
|
+
const result = safeFilter(src, (o) => o.menu_label !== 'b')
|
|
108
|
+
expect(result.length).to.equal(2)
|
|
109
|
+
expect(result[0]).to.equal(src[0]) // element reference preserved
|
|
110
|
+
expect(result.__test__revived).to.be.true // array-level identity re-wrapped
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
it('safeReject is the inverse of safeFilter', () => {
|
|
114
|
+
const src = makeProxyMarkedArr()
|
|
115
|
+
const result = safeReject(src, (o) => o.menu_label === 'b')
|
|
116
|
+
expect(result.length).to.equal(2)
|
|
117
|
+
expect(result.map(o => o.menu_label).join('')).to.equal('ac')
|
|
118
|
+
expect(result.__test__revived).to.be.true
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
it('safeMap gives callbacks writable copies of proxied elements', () => {
|
|
122
|
+
const src = [makeProxyMarkedObj()]
|
|
123
|
+
const result = safeMap(src, (el) => {
|
|
124
|
+
el.type = 'story'
|
|
125
|
+
return el
|
|
126
|
+
})
|
|
127
|
+
expect(result[0].type).to.equal('story')
|
|
128
|
+
expect(result[0].__test__revived).to.be.true // revived copy
|
|
129
|
+
expect(src[0].type).to.equal(undefined) // source untouched
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
it('safeMap passes plain elements by reference like native map', () => {
|
|
133
|
+
const plainEl = { plain: true }
|
|
134
|
+
const result = safeMap([plainEl], (el) => el)
|
|
135
|
+
expect(result[0]).to.equal(plainEl)
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
it('all three degrade to native behavior on plain arrays', () => {
|
|
139
|
+
const plain = [1, 2, 3]
|
|
140
|
+
expect(safeFilter(plain, n => n > 1)).to.deep.equal([2, 3])
|
|
141
|
+
expect(safeReject(plain, n => n > 1)).to.deep.equal([1])
|
|
142
|
+
expect(safeMap(plain, n => n * 2)).to.deep.equal([2, 4, 6])
|
|
143
|
+
})
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
describe('read-only payload facades (deepFreeze)', () => {
|
|
147
|
+
it('wraps proxy subtrees in a write-blocking facade instead of freezing', () => {
|
|
148
|
+
const proxyChild = makeProxyMarkedObj()
|
|
149
|
+
const payload = { plainKey: 1, story: proxyChild }
|
|
150
|
+
const frozen = ChannelPayload.deepFreeze(payload)
|
|
151
|
+
|
|
152
|
+
expect(Object.isFrozen(frozen)).to.be.true // plain shell frozen
|
|
153
|
+
expect(frozen.story).to.not.equal(proxyChild) // facade, not raw ref
|
|
154
|
+
expect(frozen.story.headline).to.equal('A Test Headline') // reads pass through
|
|
155
|
+
|
|
156
|
+
frozen.story.headline = 'MUTATED' // write silently blocked
|
|
157
|
+
expect(proxyChild.headline).to.equal('A Test Headline')
|
|
158
|
+
expect(frozen.story.headline).to.equal('A Test Headline')
|
|
159
|
+
})
|
|
160
|
+
|
|
161
|
+
it('reuses one facade per proxy across dispatches', () => {
|
|
162
|
+
const proxyChild = makeProxyMarkedObj()
|
|
163
|
+
const a = ChannelPayload.deepFreeze({ story: proxyChild })
|
|
164
|
+
const b = ChannelPayload.deepFreeze({ story: proxyChild })
|
|
165
|
+
expect(a.story).to.equal(b.story)
|
|
166
|
+
})
|
|
167
|
+
|
|
168
|
+
it('facade data still revives to a writable copy via safeCloneDeep', () => {
|
|
169
|
+
const payload = ChannelPayload.deepFreeze({ story: makeProxyMarkedObj() })
|
|
170
|
+
const writable = safeCloneDeep(payload)
|
|
171
|
+
writable.story.type = 'story'
|
|
172
|
+
expect(writable.story.type).to.equal('story')
|
|
173
|
+
expect(writable.story.__test__revived).to.be.true
|
|
174
|
+
})
|
|
175
|
+
})
|
|
176
|
+
|
|
177
|
+
describe('safeAugment', () => {
|
|
178
|
+
it('returns a writable revived copy carrying the extra props', () => {
|
|
179
|
+
const augmented = safeAugment(makeProxyMarkedObj(), { type: 'story', launchCode: 'hp3' })
|
|
180
|
+
expect(augmented.__test__revived).to.be.true
|
|
181
|
+
expect(augmented.type).to.equal('story')
|
|
182
|
+
expect(augmented.launchCode).to.equal('hp3')
|
|
183
|
+
expect(augmented.headline).to.equal('A Test Headline')
|
|
184
|
+
})
|
|
185
|
+
|
|
186
|
+
it('augments plain objects without mutating the source', () => {
|
|
187
|
+
const src = { a: 1 }
|
|
188
|
+
const augmented = safeAugment(src, { b: 2 })
|
|
189
|
+
expect(augmented.b).to.equal(2)
|
|
190
|
+
expect(src.b).to.equal(undefined)
|
|
191
|
+
})
|
|
192
|
+
})
|
|
193
|
+
})
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
const { expect } = require('chai')
|
|
2
2
|
import { SpyneApp, SpyneAppProperties } from '../../spyne/spyne'
|
|
3
|
-
import sanitizeData, { sanitizeAttribute } from '../../spyne/utils/sanitize-data'
|
|
3
|
+
import sanitizeData, { sanitizeAttribute, allowCustomElements } from '../../spyne/utils/sanitize-data'
|
|
4
4
|
import sanitizeHTML from '../../spyne/utils/sanitize-html'
|
|
5
5
|
import { DomElement } from '../../spyne/views/dom-element'
|
|
6
6
|
import { DomElementTemplate } from '../../spyne/views/dom-element-template'
|
|
@@ -268,6 +268,158 @@ describe('sanitization security', () => {
|
|
|
268
268
|
})
|
|
269
269
|
})
|
|
270
270
|
|
|
271
|
+
describe('anchor policy', () => {
|
|
272
|
+
it('should keep target in app mode and force noopener on _blank', () => {
|
|
273
|
+
const clean = sanitizeData('<a href="https://example.com" target="_blank">x</a>', { mode: 'app' })
|
|
274
|
+
|
|
275
|
+
expect(clean).to.include('target="_blank"')
|
|
276
|
+
expect(clean).to.include('noopener')
|
|
277
|
+
expect(clean).to.include('noreferrer')
|
|
278
|
+
})
|
|
279
|
+
|
|
280
|
+
it('should preserve existing rel values when forcing noopener', () => {
|
|
281
|
+
const clean = sanitizeData('<a href="https://example.com" target="_blank" rel="external">x</a>', { mode: 'richtext' })
|
|
282
|
+
|
|
283
|
+
expect(clean).to.include('external')
|
|
284
|
+
expect(clean).to.include('noopener')
|
|
285
|
+
})
|
|
286
|
+
|
|
287
|
+
it('should keep named-window targets and force noopener on them', () => {
|
|
288
|
+
// target="blank" (no underscore) is a legal named browsing context
|
|
289
|
+
// and the most common real-world form; it carries the same opener
|
|
290
|
+
// risk as _blank and gets the same mitigation.
|
|
291
|
+
const clean = sanitizeData('<a href="https://rxjs.dev/api/index/function/fromEvent" target="blank">fromEvent</a>', { mode: 'app' })
|
|
292
|
+
|
|
293
|
+
expect(clean).to.include('target="blank"')
|
|
294
|
+
expect(clean).to.include('noopener')
|
|
295
|
+
expect(clean).to.include('noreferrer')
|
|
296
|
+
})
|
|
297
|
+
|
|
298
|
+
it('should not add noopener to same-context targets', () => {
|
|
299
|
+
const clean = sanitizeData('<a href="https://example.com" target="_self">x</a>', { mode: 'app' })
|
|
300
|
+
|
|
301
|
+
expect(clean).to.include('target="_self"')
|
|
302
|
+
expect(clean).to.not.include('noopener')
|
|
303
|
+
})
|
|
304
|
+
|
|
305
|
+
it('should remove target from anchors without an href', () => {
|
|
306
|
+
const clean = sanitizeData('<a target="_blank">x</a>', { mode: 'app' })
|
|
307
|
+
|
|
308
|
+
expect(clean).to.not.include('target=')
|
|
309
|
+
})
|
|
310
|
+
|
|
311
|
+
it('should strip target everywhere with allowTarget false', () => {
|
|
312
|
+
const clean = sanitizeData('<a href="https://example.com" target="_blank">x</a>', {
|
|
313
|
+
mode: 'richtext',
|
|
314
|
+
anchors: { allowTarget: false }
|
|
315
|
+
})
|
|
316
|
+
|
|
317
|
+
expect(clean).to.not.include('target=')
|
|
318
|
+
})
|
|
319
|
+
|
|
320
|
+
it('should enforce allowedDomains on cross-origin hrefs', () => {
|
|
321
|
+
const opts = { mode: 'app', anchors: { allowedDomains: ['https://github.com'] } }
|
|
322
|
+
|
|
323
|
+
const allowed = sanitizeData('<a href="https://github.com/spynejs">x</a>', opts)
|
|
324
|
+
const blocked = sanitizeData('<a href="https://evil.example.com/page">x</a>', opts)
|
|
325
|
+
|
|
326
|
+
expect(allowed).to.include('href="https://github.com/spynejs"')
|
|
327
|
+
expect(blocked).to.not.include('href=')
|
|
328
|
+
})
|
|
329
|
+
|
|
330
|
+
it('should always allow same-origin, relative, mailto and tel hrefs despite allowedDomains', () => {
|
|
331
|
+
const opts = { mode: 'app', anchors: { allowedDomains: ['https://github.com'] } }
|
|
332
|
+
|
|
333
|
+
expect(sanitizeData('<a href="/local/page">x</a>', opts)).to.include('href="/local/page"')
|
|
334
|
+
expect(sanitizeData('<a href="mailto:a@b.com">x</a>', opts)).to.include('href="mailto:a@b.com"')
|
|
335
|
+
expect(sanitizeData('<a href="tel:+15551234567">x</a>', opts)).to.include('href="tel:+15551234567"')
|
|
336
|
+
})
|
|
337
|
+
|
|
338
|
+
it('should apply the anchor policy on the DomElement attribute channel', () => {
|
|
339
|
+
const el = new DomElement({
|
|
340
|
+
tagName: 'a',
|
|
341
|
+
attributes: { href: 'https://example.com', target: '_blank' }
|
|
342
|
+
}).render()
|
|
343
|
+
|
|
344
|
+
expect(el.getAttribute('target')).to.equal('_blank')
|
|
345
|
+
expect(el.getAttribute('rel')).to.include('noopener')
|
|
346
|
+
})
|
|
347
|
+
|
|
348
|
+
it('should keep named-window targets with noopener on the DomElement attribute channel', () => {
|
|
349
|
+
const el = new DomElement({
|
|
350
|
+
tagName: 'a',
|
|
351
|
+
attributes: { href: 'https://example.com', target: 'blank' }
|
|
352
|
+
}).render()
|
|
353
|
+
|
|
354
|
+
expect(el.getAttribute('target')).to.equal('blank')
|
|
355
|
+
expect(el.getAttribute('rel')).to.include('noopener')
|
|
356
|
+
})
|
|
357
|
+
})
|
|
358
|
+
|
|
359
|
+
describe('CMS wrappers and custom elements', () => {
|
|
360
|
+
const cmsMarkup = '<spyne-cms-item data-cms-id="post.title" data-cms-key="title">' +
|
|
361
|
+
'<spyne-cms-item-hitbox></spyne-cms-item-hitbox>' +
|
|
362
|
+
'<spyne-cms-item-text>Hello</spyne-cms-item-text>' +
|
|
363
|
+
'</spyne-cms-item>'
|
|
364
|
+
|
|
365
|
+
it('should keep spyne-* CMS wrappers and data-cms attributes in app mode', () => {
|
|
366
|
+
const clean = sanitizeData(cmsMarkup, { mode: 'app' })
|
|
367
|
+
|
|
368
|
+
expect(clean).to.include('<spyne-cms-item')
|
|
369
|
+
expect(clean).to.include('data-cms-key="title"')
|
|
370
|
+
expect(clean).to.include('<spyne-cms-item-hitbox')
|
|
371
|
+
expect(clean).to.include('<spyne-cms-item-text>Hello</spyne-cms-item-text>')
|
|
372
|
+
})
|
|
373
|
+
|
|
374
|
+
it('should keep spyne-* CMS wrappers in richtext mode', () => {
|
|
375
|
+
const clean = sanitizeData(cmsMarkup, { mode: 'richtext' })
|
|
376
|
+
|
|
377
|
+
expect(clean).to.include('<spyne-cms-item')
|
|
378
|
+
expect(clean).to.include('data-cms-id="post.title"')
|
|
379
|
+
})
|
|
380
|
+
|
|
381
|
+
it('should keep spyne-* CMS wrappers at the template layer', () => {
|
|
382
|
+
const clean = String(sanitizeHTML(cmsMarkup))
|
|
383
|
+
|
|
384
|
+
expect(clean).to.include('<spyne-cms-item')
|
|
385
|
+
expect(clean).to.include('<spyne-cms-item-text>Hello</spyne-cms-item-text>')
|
|
386
|
+
})
|
|
387
|
+
|
|
388
|
+
it('should still sanitize content inside CMS wrappers', () => {
|
|
389
|
+
const dirty = '<spyne-cms-item data-cms-key="body">' +
|
|
390
|
+
'<spyne-cms-item-text><script>bad()</script><b>ok</b></spyne-cms-item-text>' +
|
|
391
|
+
'</spyne-cms-item>'
|
|
392
|
+
const clean = sanitizeData(dirty, { mode: 'app' })
|
|
393
|
+
|
|
394
|
+
expect(clean).to.include('<spyne-cms-item')
|
|
395
|
+
expect(clean).to.not.include('<script')
|
|
396
|
+
expect(clean).to.include('<b>ok</b>')
|
|
397
|
+
})
|
|
398
|
+
|
|
399
|
+
it('should not allow event-handler attributes on CMS wrappers', () => {
|
|
400
|
+
const dirty = '<spyne-cms-item onclick="evil()" data-cms-key="x">y</spyne-cms-item>'
|
|
401
|
+
const clean = sanitizeData(dirty, { mode: 'app' })
|
|
402
|
+
|
|
403
|
+
expect(clean).to.include('<spyne-cms-item')
|
|
404
|
+
expect(clean).to.not.include('onclick')
|
|
405
|
+
})
|
|
406
|
+
|
|
407
|
+
it('should admit elements registered via allowCustomElements', () => {
|
|
408
|
+
allowCustomElements(['my-widget'])
|
|
409
|
+
const clean = sanitizeData('<my-widget data-config="a">w</my-widget>', { mode: 'app' })
|
|
410
|
+
|
|
411
|
+
expect(clean).to.include('<my-widget')
|
|
412
|
+
expect(clean).to.include('data-config="a"')
|
|
413
|
+
})
|
|
414
|
+
|
|
415
|
+
it('should reject invalid custom element names', () => {
|
|
416
|
+
allowCustomElements(['script', 'iframe', 'div'])
|
|
417
|
+
const clean = sanitizeData('<script>bad()</script>', { mode: 'app' })
|
|
418
|
+
|
|
419
|
+
expect(clean).to.not.include('<script')
|
|
420
|
+
})
|
|
421
|
+
})
|
|
422
|
+
|
|
271
423
|
describe('DomElementTemplate output', () => {
|
|
272
424
|
it('should sanitize renderToString output', () => {
|
|
273
425
|
const tmpl = new DomElementTemplate('<b>{{word}}</b><script>bad()</script>', { word: 'hello' })
|
|
@@ -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
|
+
})
|