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.
@@ -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.el.length).to.eq(5)
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.el[0].classList.contains('foo')
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.el[0].classList.contains('has-svg')
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.el[0].className)
70
- const isFooBarClassBool = liList.el[0].className === 'foo bar'
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.el[0].style.getPropertyValue('background') === 'orange'
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.el[0].classList.contains('foo')
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
+ })