spyne 0.25.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.
@@ -200,6 +200,117 @@ const addIframeHardeningHook = () => {
200
200
  })
201
201
  }
202
202
 
203
+ /* ---------------------------------------------
204
+ * Anchor policy
205
+ *
206
+ * Configured via config.anchors at SpyneApp.init:
207
+ * allowTarget — true (default) admits target on dynamic anchors in
208
+ * every mode; the hook validates values and forces
209
+ * noopener. false strips target everywhere.
210
+ * addNoopener — true (default) forces rel="noopener noreferrer"
211
+ * onto target="_blank" anchors.
212
+ * allowedDomains — [] (any destination the URI scheme policy allows) |
213
+ * ['https://github.com', ...] restricts cross-origin
214
+ * hrefs to the listed origins. Same-origin, relative,
215
+ * mailto: and tel: hrefs are always allowed.
216
+ *
217
+ * A per-call override can be passed as opts.anchors to sanitizeData.
218
+ * The legacy top-level allowTargetAttr and addNoopener config keys are
219
+ * honored as fallbacks.
220
+ * ------------------------------------------- */
221
+ // target accepts any browsing-context name; these are the keywords that
222
+ // stay in the same context and therefore carry no opener risk.
223
+ const SAME_CONTEXT_TARGETS = ['_self', '_parent', '_top']
224
+
225
+ let configuredAnchorPolicy = { allowTarget: true, addNoopener: true, allowedDomains: [] }
226
+ let activeAnchorPolicy = configuredAnchorPolicy
227
+
228
+ const resolveAnchorPolicy = (override) => {
229
+ if (!override) return configuredAnchorPolicy
230
+ return {
231
+ allowTarget: override.allowTarget !== undefined ? override.allowTarget === true : configuredAnchorPolicy.allowTarget,
232
+ addNoopener: override.addNoopener !== undefined ? override.addNoopener === true : configuredAnchorPolicy.addNoopener,
233
+ allowedDomains: override.allowedDomains !== undefined ? override.allowedDomains : configuredAnchorPolicy.allowedDomains
234
+ }
235
+ }
236
+
237
+ const isAllowedAnchorHref = (href, policy = activeAnchorPolicy) => {
238
+ const normalized = normalizeUriForCheck(href)
239
+
240
+ // mailto: and tel: are not domains and always pass.
241
+ if (/^(mailto|tel):/i.test(normalized)) return true
242
+
243
+ let resolved
244
+ try {
245
+ resolved = new URL(normalized, window.location.href)
246
+ } catch (e) {
247
+ return false
248
+ }
249
+
250
+ // Same-origin links (including relative hrefs) are never restricted.
251
+ if (resolved.origin === window.location.origin && resolved.origin !== 'null') {
252
+ return true
253
+ }
254
+
255
+ if (/^https?:$/.test(resolved.protocol) !== true) return false
256
+
257
+ const domains = policy.allowedDomains
258
+ if (!Array.isArray(domains) || domains.length === 0) return true
259
+
260
+ return domains.some(d => {
261
+ try {
262
+ return new URL(d).origin === resolved.origin
263
+ } catch (e) {
264
+ return false
265
+ }
266
+ })
267
+ }
268
+
269
+ const forceNoopenerRel = (el) => {
270
+ const currentRel = el.getAttribute('rel') || ''
271
+ const relSet = new Set(currentRel.split(/\s+/).filter(Boolean))
272
+
273
+ relSet.add('noopener')
274
+ relSet.add('noreferrer')
275
+
276
+ el.setAttribute('rel', Array.from(relSet).join(' '))
277
+ }
278
+
279
+ let anchorHookAdded = false
280
+ const addAnchorHardeningHook = () => {
281
+ if (anchorHookAdded) return
282
+ anchorHookAdded = true
283
+
284
+ DOMPurify.addHook('afterSanitizeAttributes', (node) => {
285
+ if (!node || typeof node.getAttribute !== 'function') return
286
+ if (node.tagName !== 'A') return
287
+
288
+ const policy = activeAnchorPolicy
289
+ const href = node.getAttribute('href')
290
+
291
+ if (href && policy.allowedDomains.length > 0 && isAllowedAnchorHref(href, policy) !== true) {
292
+ spyneWarn(`SPYNE WARNING: anchor href "${href}" was removed by the anchor policy (same-origin or an allowed domain is required).`)
293
+ node.removeAttribute('href')
294
+ }
295
+
296
+ const target = node.getAttribute('target')
297
+ if (!target) return
298
+
299
+ // target without an href serves no purpose.
300
+ if (policy.allowTarget !== true || !node.getAttribute('href')) {
301
+ node.removeAttribute('target')
302
+ return
303
+ }
304
+
305
+ // Any browsing-context name is valid; _blank and named windows both
306
+ // hand the opened page an opener reference, so noopener applies to
307
+ // every target except the same-context keywords.
308
+ if (SAME_CONTEXT_TARGETS.includes(target) !== true && policy.addNoopener === true) {
309
+ forceNoopenerRel(node)
310
+ }
311
+ })
312
+ }
313
+
203
314
  /* ---------------------------------------------
204
315
  * Determine sanitization mode
205
316
  *
@@ -239,13 +350,27 @@ const buildModeConfig = (mode) => {
239
350
  }
240
351
  }
241
352
 
242
- if (mode === 'app' && allowIframe === true) {
243
- return {
244
- ...base,
245
- FORBID_TAGS: base.FORBID_TAGS.filter(t => t !== 'iframe'),
246
- ADD_TAGS: ['iframe'],
247
- ADD_ATTR: ['sandbox']
353
+ if (mode === 'app') {
354
+ const cfg = { ...base }
355
+ const addAttr = []
356
+
357
+ if (allowIframe === true) {
358
+ cfg.FORBID_TAGS = base.FORBID_TAGS.filter(t => t !== 'iframe')
359
+ cfg.ADD_TAGS = ['iframe']
360
+ addAttr.push('sandbox')
361
+ }
362
+
363
+ // DOMPurify strips target by default; re-admit it (with rel) so the
364
+ // anchor hook can validate values and force noopener instead.
365
+ if (activeAnchorPolicy.allowTarget === true) {
366
+ addAttr.push('target', 'rel')
367
+ }
368
+
369
+ if (addAttr.length > 0) {
370
+ cfg.ADD_ATTR = addAttr
248
371
  }
372
+
373
+ return cfg
249
374
  }
250
375
 
251
376
  return base
@@ -266,7 +391,7 @@ const sanitizeDataConfigure = (config = {}) => {
266
391
  return
267
392
  }
268
393
 
269
- const { strict = false, disableSanitize = false, iframes, customElements } = config
394
+ const { strict = false, disableSanitize = false, iframes, customElements, anchors, allowTargetAttr, addNoopener } = config
270
395
 
271
396
  globalDisable = disableSanitize === true
272
397
 
@@ -279,10 +404,20 @@ const sanitizeDataConfigure = (config = {}) => {
279
404
  activeIframePolicy = configuredIframePolicy
280
405
  }
281
406
 
407
+ // config.anchors wins; the legacy top-level allowTargetAttr/addNoopener
408
+ // keys are honored as fallbacks.
409
+ configuredAnchorPolicy = resolveAnchorPolicy({
410
+ allowTarget: anchors?.allowTarget !== undefined ? anchors.allowTarget : allowTargetAttr,
411
+ addNoopener: anchors?.addNoopener !== undefined ? anchors.addNoopener : addNoopener,
412
+ allowedDomains: anchors?.allowedDomains
413
+ })
414
+ activeAnchorPolicy = configuredAnchorPolicy
415
+
282
416
  if (globalDisable) {
283
417
  console.warn('SPYNE SECURITY WARNING: All data sanitization is DISABLED via config.disableSanitize. Do not use this setting in production.')
284
418
  } else {
285
419
  addIframeHardeningHook()
420
+ addAnchorHardeningHook()
286
421
  }
287
422
 
288
423
  const processFactory = (mode) => {
@@ -302,19 +437,21 @@ const sanitizeDataConfigure = (config = {}) => {
302
437
  }
303
438
 
304
439
  _sanitizeData = (data, opts = {}) => {
305
- const { disableSanitize: callDisable = false, mode, iframes: iframesOverride } = opts
440
+ const { disableSanitize: callDisable = false, mode, iframes: iframesOverride, anchors: anchorsOverride } = opts
306
441
  if (globalDisable || callDisable) return data
307
442
 
308
443
  const legacyStrict = forceStrict || strict
309
444
  const effectiveMode = mode || (legacyStrict ? 'app' : resolveDefaultMode())
310
445
 
311
- // Sanitization is synchronous, so the per-call iframe policy can be
312
- // scoped with a swap-and-restore around the processing pass.
446
+ // Sanitization is synchronous, so the per-call iframe and anchor
447
+ // policies can be scoped with a swap-and-restore around the pass.
313
448
  activeIframePolicy = resolveIframePolicy(iframesOverride)
449
+ activeAnchorPolicy = resolveAnchorPolicy(anchorsOverride)
314
450
  try {
315
451
  return processFactory(effectiveMode)(data)
316
452
  } finally {
317
453
  activeIframePolicy = configuredIframePolicy
454
+ activeAnchorPolicy = configuredAnchorPolicy
318
455
  }
319
456
  }
320
457
 
@@ -387,6 +524,16 @@ const sanitizeAttribute = (key, value, tagName) => {
387
524
  return { allowed: isAllowedIframeSrc(value, configuredIframePolicy), value }
388
525
  }
389
526
 
527
+ // Anchor href follows the anchor policy: safe scheme, plus the
528
+ // configured domain allowlist for cross-origin destinations.
529
+ if (tag === 'A' && k === 'href') {
530
+ if (isSafeUri(value) !== true) return { allowed: false, value }
531
+ if (configuredAnchorPolicy.allowedDomains.length > 0 && isAllowedAnchorHref(value, configuredAnchorPolicy) !== true) {
532
+ return { allowed: false, value }
533
+ }
534
+ return { allowed: true, value }
535
+ }
536
+
390
537
  if (URL_ATTRS.includes(k) && isSafeUri(value) !== true) {
391
538
  return { allowed: false, value }
392
539
  }
@@ -395,16 +542,31 @@ const sanitizeAttribute = (key, value, tagName) => {
395
542
  }
396
543
 
397
544
  /**
398
- * Applies the configured iframe sandbox policy to an element created
545
+ * Applies the configured iframe and anchor policies to an element created
399
546
  * through the attribute channel (DomElement), mirroring the DOMPurify
400
- * hook that guards parsed HTML.
547
+ * hooks that guard parsed HTML.
401
548
  */
402
- const applyIframeHardening = (el) => {
403
- if (globalDisable || !el || el.tagName !== 'IFRAME') return el
549
+ const applyElementHardening = (el) => {
550
+ if (globalDisable || !el) return el
551
+
552
+ if (el.tagName === 'IFRAME') {
553
+ const { sandbox } = configuredIframePolicy
554
+ if (sandbox !== false && el.hasAttribute('sandbox') !== true) {
555
+ el.setAttribute('sandbox', sandbox)
556
+ }
557
+ }
558
+
559
+ if (el.tagName === 'A') {
560
+ const policy = configuredAnchorPolicy
561
+ const target = el.getAttribute('target')
404
562
 
405
- const { sandbox } = configuredIframePolicy
406
- if (sandbox !== false && el.hasAttribute('sandbox') !== true) {
407
- el.setAttribute('sandbox', sandbox)
563
+ if (target) {
564
+ if (policy.allowTarget !== true || !el.getAttribute('href')) {
565
+ el.removeAttribute('target')
566
+ } else if (SAME_CONTEXT_TARGETS.includes(target) !== true && policy.addNoopener === true) {
567
+ forceNoopenerRel(el)
568
+ }
569
+ }
408
570
  }
409
571
 
410
572
  return el
@@ -442,7 +604,7 @@ export {
442
604
  sanitizeData,
443
605
  sanitizeDataForce,
444
606
  sanitizeAttribute,
445
- applyIframeHardening,
607
+ applyElementHardening,
446
608
  allowCustomElements,
447
609
  CUSTOM_ELEMENT_HANDLING as customElementHandling,
448
610
  sanitizeEventTarget,
@@ -28,9 +28,9 @@ const sanitizeHTMLConfigure = (config = {}) => {
28
28
  mode = 'app',
29
29
  disableSanitize = false,
30
30
  allowTargetAttr = true,
31
- addNoopener = true,
32
31
  debug = false,
33
- iframes = {}
32
+ iframes = {},
33
+ anchors = {}
34
34
  } = config
35
35
 
36
36
  if (disableSanitize === true) {
@@ -48,6 +48,12 @@ const sanitizeHTMLConfigure = (config = {}) => {
48
48
  // is admitted here.
49
49
  const allowIframe = iframes.allow !== undefined ? iframes.allow === true : isRichtext
50
50
 
51
+ // config.anchors.allowTarget wins; the legacy top-level allowTargetAttr
52
+ // key is honored as a fallback. Target values and noopener enforcement
53
+ // are handled by the anchor hook registered in sanitize-data, which
54
+ // applies to this layer's sanitization as well.
55
+ const allowTarget = anchors.allowTarget !== undefined ? anchors.allowTarget === true : allowTargetAttr === true
56
+
51
57
  const domPurifyConfig = {
52
58
  RETURN_TRUSTED_TYPE: false,
53
59
  ADD_TAGS: [
@@ -55,7 +61,7 @@ const sanitizeHTMLConfigure = (config = {}) => {
55
61
  ...(isRichtext ? ['link'] : [])
56
62
  ],
57
63
  ADD_ATTR: [
58
- ...(allowTargetAttr ? ['target'] : []),
64
+ ...(allowTarget ? ['target', 'rel'] : []),
59
65
  ...(allowIframe ? ['sandbox'] : [])
60
66
  ],
61
67
  // Admits the framework's spyne-* elements (CMS proxy wrappers) and any
@@ -67,41 +73,6 @@ const sanitizeHTMLConfigure = (config = {}) => {
67
73
 
68
74
  const sanitizeWithPolicy = (html) => DOMPurify.sanitize(html, domPurifyConfig)
69
75
 
70
- if (allowTargetAttr) {
71
- DOMPurify.addHook('afterSanitizeAttributes', (node) => {
72
- if (!node || typeof node.getAttribute !== 'function') return
73
- if (node.tagName !== 'A') return
74
-
75
- const href = node.getAttribute('href')
76
- const target = node.getAttribute('target')
77
-
78
- // remove target on anchors without href
79
- if (!href && target) {
80
- node.removeAttribute('target')
81
- return
82
- }
83
-
84
- if (!target) return
85
-
86
- const validTargets = ['_blank', '_self', '_parent', '_top']
87
- if (!validTargets.includes(target)) {
88
- node.removeAttribute('target')
89
- return
90
- }
91
-
92
- if (target === '_blank' && addNoopener) {
93
- const currentRel = node.getAttribute('rel') || ''
94
- const relParts = currentRel.split(/\s+/).filter(Boolean)
95
- const relSet = new Set(relParts)
96
-
97
- relSet.add('noopener')
98
- relSet.add('noreferrer')
99
-
100
- node.setAttribute('rel', Array.from(relSet).join(' '))
101
- }
102
- })
103
- }
104
-
105
76
  _sanitizeHTML = sanitizeWithPolicy
106
77
 
107
78
  if (strict === true && window.trustedTypes) {
@@ -9,6 +9,8 @@ let _channelsMap
9
9
  let _initialized
10
10
  let _debug = true
11
11
  let _enableCMSProxies = false
12
+ let _cmsRehydrator
13
+ let _cmsLineageWarner
12
14
  const _excludeChannelsFromConsole = []
13
15
  /* eslint-disable */
14
16
  let _linksData
@@ -250,6 +252,34 @@ class SpyneAppPropertiesClass {
250
252
  return _proxiesMap.get(proxyName)
251
253
  }
252
254
 
255
+ /**
256
+ * Registers the CMS re-hydration hook consulted by DomElementTemplate when
257
+ * it receives plain (unproxied) data while CMS proxies are enabled. The
258
+ * hook receives the template data and returns either revived proxy data
259
+ * for that content or undefined to decline. Implementations must be cheap
260
+ * for non-CMS data — ideally a single own-property read before bailing.
261
+ */
262
+ registerCmsRehydrator(method) {
263
+ _cmsRehydrator = method
264
+ }
265
+
266
+ get rehydrateCmsData() {
267
+ return _cmsRehydrator
268
+ }
269
+
270
+ /**
271
+ * Registers the debug-mode hook DomElementTemplate calls when data renders
272
+ * unproxied while CMS proxies are enabled and re-hydration declined —
273
+ * the lineage reporter that turns silent proxy strips into warnings.
274
+ */
275
+ registerCmsLineageWarner(method) {
276
+ _cmsLineageWarner = method
277
+ }
278
+
279
+ get warnUnproxiedCmsData() {
280
+ return _cmsLineageWarner
281
+ }
282
+
253
283
  getPluginConfigByPluginName(pluginName) {
254
284
  return _config.plugins[pluginName]
255
285
  }
@@ -36,6 +36,27 @@ export class DomElementTemplate {
36
36
  this.isProxyData = data.__cms__isProxy === true
37
37
  this.testMode = opts?.testMode
38
38
 
39
+ // CMS lineage pass — construction-time only, and only in authoring mode.
40
+ // In production (enableCMSProxies false) this costs one boolean read;
41
+ // nothing here ever runs inside the substitution loops.
42
+ if (this.isProxyData === false && SpyneAppProperties.enableCMSProxies === true) {
43
+ const rehydrated = SpyneAppProperties.rehydrateCmsData?.(data)
44
+ if (rehydrated !== undefined) {
45
+ // a stripped clone was re-identified — render with the revived proxy
46
+ data = rehydrated
47
+ this.isProxyData = true
48
+ } else if (DomElementTemplate.templateBindsProxiedChild(data, this.template) === true) {
49
+ // plain wrapper holding proxied values (e.g. a pageData container),
50
+ // AND this template actually references one of those proxied children
51
+ // via a dot-path or loop token — only then is CMS wrapping useful.
52
+ // Flipping on child presence alone put ~40% of all templates through
53
+ // the wrap→inflate→sanitize→strip pipeline for zero bindings.
54
+ this.isProxyData = true
55
+ } else if (SpyneAppProperties.debug === true) {
56
+ SpyneAppProperties.warnUnproxiedCmsData?.(data, this.template)
57
+ }
58
+ }
59
+
39
60
  // Normalize triple-bracket tags to double-bracket.
40
61
  //
41
62
  // DomElementTemplate treats {{{key}}} as an alias for {{key}} — both
@@ -105,6 +126,57 @@ export class DomElementTemplate {
105
126
  return /({{\.\*?}})/.test(str)
106
127
  }
107
128
 
129
+ /**
130
+ * Shallow, one-level scan for CMS-proxied values inside a plain container,
131
+ * bailing on the first hit. Lets wrappers that hold proxied content (a
132
+ * pageData object, a filtered array of proxied stories) opt in to CMS
133
+ * template wrapping even though the wrapper itself is not a proxy.
134
+ * Only ever called in authoring mode (enableCMSProxies true).
135
+ */
136
+ /**
137
+ * Wrapper detection with a binding test: returns true only when data holds
138
+ * a proxied child under a key that the template actually references via a
139
+ * dot-path ({{key.prop}}) or loop ({{#key}} / {{#key.path}}) token. A
140
+ * template that never reaches into the proxied child gains nothing from
141
+ * CMS wrapping — its own top-level tokens have no dataId to bind — so
142
+ * flipping it into proxy mode only pays the wrap/sanitize/strip pipeline.
143
+ */
144
+ static templateBindsProxiedChild(data, template) {
145
+ if (data === null || typeof data !== 'object' || typeof template !== 'string') {
146
+ return false
147
+ }
148
+ if (template.indexOf('{{') === -1) {
149
+ return false
150
+ }
151
+ const keys = Object.keys(data)
152
+ for (let i = 0; i < keys.length; i++) {
153
+ const key = keys[i]
154
+ const val = data[key]
155
+ if (val !== null && typeof val === 'object' && val.__cms__isProxy === true) {
156
+ if (template.indexOf(`{{${key}.`) !== -1 ||
157
+ template.indexOf(`{{#${key}}}`) !== -1 ||
158
+ template.indexOf(`{{#${key}.`) !== -1) {
159
+ return true
160
+ }
161
+ }
162
+ }
163
+ return false
164
+ }
165
+
166
+ static hasProxiedChild(data) {
167
+ if (data === null || typeof data !== 'object') {
168
+ return false
169
+ }
170
+ const values = Object.values(data)
171
+ for (let i = 0; i < values.length; i++) {
172
+ const val = values[i]
173
+ if (val !== null && typeof val === 'object' && val.__cms__isProxy === true) {
174
+ return true
175
+ }
176
+ }
177
+ return false
178
+ }
179
+
108
180
  /**
109
181
  * Normalizes triple-bracket tags to double-bracket form.
110
182
  *
@@ -219,8 +291,26 @@ export class DomElementTemplate {
219
291
  /**
220
292
  * @desc Returns a document fragment generated from the template and any added data.
221
293
  */
294
+ /**
295
+ * Unwraps <spyne-cms-item> elements whose data-cms-id resolved to an empty
296
+ * string — tokens that live on a plain wrapper (no __cms__dataId) rather
297
+ * than on CMS content. Leaves their inner text in place, so the rendered
298
+ * DOM matches what a non-CMS render would produce. Only runs in authoring
299
+ * mode (isProxyData true), one pass over the final HTML string.
300
+ */
301
+ static stripInertCmsItems(html) {
302
+ if (html.indexOf('data-cms-id=""') === -1) {
303
+ return html
304
+ }
305
+ const inertRE = /<spyne-cms-item data-cms-id=""[^>]*>[\s\S]*?<spyne-cms-item-text>([\s\S]*?)<\/spyne-cms-item-text>\s*<\/spyne-cms-item>/g
306
+ return html.replace(inertRE, '$1')
307
+ }
308
+
222
309
  renderDocFrag() {
223
310
  let html = DomElementTemplate.replaceImgPath(this.finalArr.join(''))
311
+ if (this.isProxyData === true) {
312
+ html = DomElementTemplate.stripInertCmsItems(html)
313
+ }
224
314
  if (this.testMode !== true) {
225
315
  html = sanitizeHTML(html)
226
316
  }
@@ -234,6 +324,9 @@ export class DomElementTemplate {
234
324
  renderToString() {
235
325
  let html = this.finalArr.join('')
236
326
  html = DomElementTemplate.replaceImgPath(html)
327
+ if (this.isProxyData === true) {
328
+ html = DomElementTemplate.stripInertCmsItems(html)
329
+ }
237
330
  if (this.testMode !== true) {
238
331
  // String(...) unwraps TrustedHTML when strict mode is active,
239
332
  // keeping this method's string contract.
@@ -2,7 +2,7 @@ import { baseCoreMixins } from '../utils/mixins/base-core-mixins.js'
2
2
  import { spyneWarn } from '../utils/spyne-warn.js'
3
3
  import { DomElementTemplate } from './dom-element-template.js'
4
4
  import { deepMerge } from '../utils/deep-merge.js'
5
- import { sanitizeAttribute, applyIframeHardening } from '../utils/sanitize-data.js'
5
+ import { sanitizeAttribute, applyElementHardening } from '../utils/sanitize-data.js'
6
6
  import { is, defaultTo, pick, mapObjIndexed, forEachObjIndexed, pipe } from 'ramda'
7
7
 
8
8
  class DomElement {
@@ -79,7 +79,7 @@ class DomElement {
79
79
  this.getProp('attrs').forEach(addAttributes)
80
80
 
81
81
  if (testMode !== true) {
82
- applyIframeHardening(el)
82
+ applyElementHardening(el)
83
83
  }
84
84
 
85
85
  return el
@@ -191,6 +191,32 @@ describe('should test channel payload filters boolean correctness', () => {
191
191
 
192
192
  return true
193
193
  })
194
+
195
+ it('should return false for an invalid css selector instead of throwing', () => {
196
+ const cpFilter = new ChannelPayloadFilter('!!!not-a-selector')
197
+ const payloadBool = cpFilter(ChannelPayloadToTestFilters)
198
+ expect(payloadBool).to.be.false
199
+ })
200
+
201
+ it('should not match a structural lookalike of a selector match', () => {
202
+ // two identical siblings: the selector matches only the first, the
203
+ // payload el is the second — Element.matches tests the payload el
204
+ // itself, so the structurally equal non-matching twin must NOT pass
205
+ const ul = document.createElement('ul')
206
+ ul.innerHTML = '<li class="twin"><b>same</b></li><li class="twin"><b>same</b></li>'
207
+ document.body.appendChild(ul)
208
+ const cpFilter = new ChannelPayloadFilter('li.twin:first-child')
209
+ const payloadBool = cpFilter({ srcElement: { el: ul.querySelectorAll('li.twin')[1] } })
210
+ expect(payloadBool).to.be.false
211
+ })
212
+
213
+ it('should match a detached element against its selector', () => {
214
+ const li = document.createElement('li')
215
+ li.className = 'detached-item'
216
+ const cpFilter = new ChannelPayloadFilter('li.detached-item')
217
+ const payloadBool = cpFilter({ srcElement: { el: li } })
218
+ expect(payloadBool).to.be.true
219
+ })
194
220
  })
195
221
 
196
222
  describe('it should test channel payload filter with data packer ', () => {