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,26 @@
1
+ /**
2
+ * Central outlet for SpyneJS developer-hint warnings (invalid selectors,
3
+ * sanitized attributes, policy removals, deprecations).
4
+ *
5
+ * Warnings are suppressed when window.SPYNE_SUPPRESS_WARNINGS is true —
6
+ * primarily for test runs, where expected-failure cases would otherwise
7
+ * flood the reporter. The flag lives on window (not module state) so it
8
+ * spans separately bundled test files.
9
+ *
10
+ * Security-posture warnings (e.g. config.disableSanitize) do NOT route
11
+ * through here and are always printed.
12
+ */
13
+ const spyneWarn = (...args) => {
14
+ const suppressed = typeof window !== 'undefined' && window.SPYNE_SUPPRESS_WARNINGS === true
15
+ if (suppressed !== true) {
16
+ console.warn(...args)
17
+ }
18
+ }
19
+
20
+ const setSpyneWarningsDisabled = (bool = true) => {
21
+ if (typeof window !== 'undefined') {
22
+ window.SPYNE_SUPPRESS_WARNINGS = bool === true
23
+ }
24
+ }
25
+
26
+ export { spyneWarn, setSpyneWarningsDisabled }
@@ -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.
@@ -1,7 +1,8 @@
1
1
  import { baseCoreMixins } from '../utils/mixins/base-core-mixins.js'
2
+ import { spyneWarn } from '../utils/spyne-warn.js'
2
3
  import { DomElementTemplate } from './dom-element-template.js'
3
4
  import { deepMerge } from '../utils/deep-merge.js'
4
- import { sanitizeAttribute, applyIframeHardening } from '../utils/sanitize-data.js'
5
+ import { sanitizeAttribute, applyElementHardening } from '../utils/sanitize-data.js'
5
6
  import { is, defaultTo, pick, mapObjIndexed, forEachObjIndexed, pipe } from 'ramda'
6
7
 
7
8
  class DomElement {
@@ -67,7 +68,7 @@ class DomElement {
67
68
  if (testMode !== true) {
68
69
  const { allowed, value } = sanitizeAttribute(key, val, el.tagName)
69
70
  if (allowed !== true) {
70
- console.warn(`SPYNE WARNING: The attribute "${key}" was removed during sanitization.`, el)
71
+ spyneWarn(`SPYNE WARNING: The attribute "${key}" was removed during sanitization.`, el)
71
72
  return
72
73
  }
73
74
  val = value
@@ -78,7 +79,7 @@ class DomElement {
78
79
  this.getProp('attrs').forEach(addAttributes)
79
80
 
80
81
  if (testMode !== true) {
81
- applyIframeHardening(el)
82
+ applyElementHardening(el)
82
83
  }
83
84
 
84
85
  return el
@@ -1,4 +1,5 @@
1
1
  import { head, compose, reject, split, isEmpty, lte, defaultTo, prop } from 'ramda'
2
+ import { spyneWarn } from '../utils/spyne-warn.js'
2
3
  import { SpyneAppProperties } from '../utils/spyne-app-properties.js'
3
4
 
4
5
  function generateSpyneSelectorId(el) {
@@ -32,7 +33,7 @@ function testSelectors(cxt, sel, verboseBool) {
32
33
  const elIsDomElement = compose(lte(0), defaultTo(-1), prop('nodeType'))
33
34
 
34
35
  if (el !== null && elIsDomElement(el) === false) {
35
- console.warn(`Spyne Warning: the el object is not a valid single element, ${el}`)
36
+ spyneWarn(`Spyne Warning: the el object is not a valid single element, ${el}`)
36
37
  return
37
38
  }
38
39
 
@@ -48,7 +49,7 @@ function testSelectors(cxt, sel, verboseBool) {
48
49
 
49
50
  const isValidSelectorBool = isValidSelector(sel)
50
51
  if (verboseBool === true && isDevMode() === true && isValidSelectorBool === false) {
51
- console.warn(`Spyne Warning: the selector, ${sel} does not exist in this el, ${cxt}`)
52
+ spyneWarn(`Spyne Warning: the selector, ${sel} does not exist in this el, ${cxt}`)
52
53
  }
53
54
  }
54
55
  }
@@ -127,8 +128,6 @@ function ViewStreamSelector(cxt, sel) {
127
128
  */
128
129
  selector.forEach = (fn) => Array.from(getNodeListArray(cxt, sel)).map(fn)
129
130
 
130
- selector.getNodeListArray = () => getNodeListArray(cxt, sel)
131
-
132
131
  /**
133
132
  *
134
133
  * Adds the class to the Element or to the NodeList.
@@ -142,7 +141,7 @@ function ViewStreamSelector(cxt, sel) {
142
141
  const arr = getNodeListArray(cxt, sel)
143
142
  const addClass = item => item.classList.add(c)
144
143
  arr.forEach(addClass)
145
- return this
144
+ return selector
146
145
  }
147
146
 
148
147
  /**
@@ -156,7 +155,7 @@ function ViewStreamSelector(cxt, sel) {
156
155
  item.classList.remove(c)
157
156
  }
158
157
  arr.forEach(removeClass)
159
- return this
158
+ return selector
160
159
  }
161
160
 
162
161
  /**
@@ -181,7 +180,7 @@ function ViewStreamSelector(cxt, sel) {
181
180
  classStrArr.forEach(adder)
182
181
  }
183
182
  arr.forEach(setTheClass)
184
- return this
183
+ return selector
185
184
  }
186
185
 
187
186
  selector.unmount = () => {
@@ -205,23 +204,25 @@ function ViewStreamSelector(cxt, sel) {
205
204
  bool ? item.classList.add(c) : item.classList.remove(c)
206
205
  }
207
206
  arr.forEach(toggleClass)
208
- return this
207
+ return selector
209
208
  }
210
209
 
211
210
  selector.toggle = (c, bool) => {
212
211
  selector.toggleClass(c, bool)
213
- return this
212
+ return selector
214
213
  }
215
214
 
216
215
  /**
217
- * Attaches html to the Selector's element
216
+ * Attaches html to the Selector's element (the first match when the
217
+ * selector matches multiple elements).
218
218
  * @param htmlElement
219
219
  */
220
220
  selector.appendChild = (htmlElement) => {
221
- if (selector.el.length !== 0) {
222
- selector.el.appendChild(htmlElement)
221
+ const el = selector.el
222
+ if (el !== null) {
223
+ el.appendChild(htmlElement)
223
224
  } else {
224
- console.warn(`Spyne Warning: The selector, ${sel} does not appear to be valid!`)
225
+ spyneWarn(`Spyne Warning: The selector, ${sel} does not appear to be valid!`)
225
226
  }
226
227
 
227
228
  return selector.el
@@ -244,7 +245,7 @@ function ViewStreamSelector(cxt, sel) {
244
245
  arr.forEach(addClass)
245
246
  }
246
247
  requestAnimationFrame(delayAddClass)
247
- // window.setTimeout(delayAddClass, 1);
248
+ return selector
248
249
  }
249
250
 
250
251
  /**
@@ -253,19 +254,6 @@ function ViewStreamSelector(cxt, sel) {
253
254
  * @param {String|HTMLElement} elSel The selector for the element.
254
255
  * @desc Sets the class active HTMLElement from a NodeList.
255
256
  */
256
- selector.setActiveItem2 = (c, elSel) => {
257
- const arr = getNodeListArray(cxt, sel)
258
- const currentEl = typeof (elSel) === 'string' ? getElOrList(cxt, elSel) : elSel
259
- const toggleBool = item => item.isEqualNode(currentEl) ? item.classList.add(c) : item.classList.remove(c)
260
- if (isNodeElement(currentEl) === true) {
261
- arr.forEach(toggleBool)
262
- } else if (isDevMode() === true) {
263
- // console.log("SEL IS ",elSel,c);
264
- console.warn(`Spyne Warning: The selector, ${elSel}, does not appear to be a valid item in setActiveItem: ${c}`)
265
- }
266
- return this
267
- }
268
-
269
257
  selector.setActiveItem = (c, elSel) => {
270
258
  const arr = getNodeListArray(cxt, sel)
271
259
  const currentEl = typeof elSel === 'string'
@@ -280,17 +268,17 @@ function ViewStreamSelector(cxt, sel) {
280
268
  if (matchingEl) {
281
269
  matchingEl.classList.add(c)
282
270
  } else if (isDevMode() === true) {
283
- console.warn(
271
+ spyneWarn(
284
272
  `Spyne Warning: The selector, ${elSel}, is valid but does not match any item in setActiveItem: ${c}`
285
273
  )
286
274
  }
287
275
  } else if (isDevMode() === true) {
288
- console.warn(
276
+ spyneWarn(
289
277
  `Spyne Warning: The selector, ${elSel}, does not appear to be a valid item in setActiveItem: ${c}`
290
278
  )
291
279
  }
292
280
 
293
- return this
281
+ return selector
294
282
  }
295
283
 
296
284
  /**
@@ -298,10 +286,24 @@ function ViewStreamSelector(cxt, sel) {
298
286
  * @function el
299
287
  *
300
288
  * @desc
301
- * getter for the selector
289
+ * getter that always resolves to a single element: the matched element,
290
+ * or the first item when the selector matches multiple elements (a
291
+ * warning is logged in debug mode).
302
292
  *
303
293
  * @returns
304
- * The a single element or a NodeList from the selector
294
+ * An HTMLElement, or null when the selector matches nothing
295
+ * (mirroring querySelector)
296
+ */
297
+
298
+ /**
299
+ *
300
+ * @function els
301
+ *
302
+ * @desc
303
+ * getter that always resolves to an Array of the matched elements.
304
+ *
305
+ * @returns
306
+ * An Array of HTMLElements; empty when the selector matches nothing
305
307
  */
306
308
 
307
309
  /**
@@ -312,7 +314,7 @@ function ViewStreamSelector(cxt, sel) {
312
314
  * Determines the length of the NodeList
313
315
  *
314
316
  * @returns
315
- * The length of the selector as a NodeList
317
+ * The number of elements matched by the selector
316
318
  */
317
319
 
318
320
  /**
@@ -326,24 +328,27 @@ function ViewStreamSelector(cxt, sel) {
326
328
  * Boolean
327
329
  */
328
330
 
329
- Object.defineProperty(selector, 'el', { get: () => getElOrList(cxt, sel, true) })
330
- Object.defineProperty(selector, 'els', { get: () => getNodeListArray(cxt, sel) })
331
- Object.defineProperty(selector, 'len', { get: () => getNodeListArray(cxt, sel, false).length })
332
- Object.defineProperty(selector, 'exists', { get: () => getNodeListArray(cxt, sel, false).length >= 1 })
333
- Object.defineProperty(selector, 'exist', { get: () => getNodeListArray(cxt, sel, false).length >= 1 })
334
- Object.defineProperty(selector, 'nodeList', { get: () => getNodeListArray(cxt, sel) })
335
- Object.defineProperty(selector, 'arr', {
331
+ const getElsArray = (verboseBool = true) => Array.from(getNodeListArray(cxt, sel, verboseBool))
332
+
333
+ Object.defineProperty(selector, 'el', {
336
334
  get: () => {
337
- const el = getElOrList(cxt, sel, true)
338
- if (el === undefined) {
339
- return []
340
- } else if (el.length === undefined) {
341
- return [el]
342
- } else {
343
- return Array.from(el)
335
+ const list = getElsArray()
336
+ if (list.length > 1 && isDevMode() === true) {
337
+ spyneWarn(`Spyne Warning: el$("${sel !== undefined ? sel : cxt}").el matched ${list.length} elements; returning the first. Use .els for the full list.`)
344
338
  }
339
+ return list.length >= 1 ? list[0] : null
345
340
  }
346
341
  })
342
+ Object.defineProperty(selector, 'els', { get: () => getElsArray() })
343
+ Object.defineProperty(selector, 'arr', { get: () => getElsArray() })
344
+
345
+ // Undocumented. Retains the pre-0.24 polymorphic el contract — a single
346
+ // element, a NodeList, or an empty NodeList depending on match count —
347
+ // to assist in debugging and migrating older SpyneJS apps.
348
+ Object.defineProperty(selector, 'elLegacy', { get: () => getElOrList(cxt, sel, true) })
349
+ Object.defineProperty(selector, 'len', { get: () => getNodeListArray(cxt, sel, false).length })
350
+ Object.defineProperty(selector, 'length', { get: () => getNodeListArray(cxt, sel, false).length })
351
+ Object.defineProperty(selector, 'exists', { get: () => getNodeListArray(cxt, sel, false).length >= 1 })
347
352
 
348
353
  Object.defineProperty(selector, 'inline', { set: (val) => setInlineCss(val, cxt, sel), get: () => selector })
349
354
  Object.defineProperty(selector, 'inlineCss', { set: (val) => setInlineCss(val, cxt, sel), get: () => selector })
@@ -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 ', () => {
@@ -0,0 +1,130 @@
1
+ import { SpyneChannelWindow } from '../../spyne/channels/spyne-channel-window'
2
+ import { Subject } from 'rxjs'
3
+ const { expect } = require('chai')
4
+
5
+ const conformedEvent = (detail) => {
6
+ const event = new CustomEvent('my_test_event', { detail })
7
+ return {
8
+ action: 'CHANNEL_WINDOW_MY_TEST_EVENT_EVENT',
9
+ payload: event,
10
+ srcElement: undefined,
11
+ event
12
+ }
13
+ }
14
+
15
+ describe('window channel customEvents config conforming', () => {
16
+ it('should conform a string entry', () => {
17
+ const entry = SpyneChannelWindow.conformCustomEventConfig('my_event')
18
+ expect(entry).to.deep.equal({ name: 'my_event', operator: undefined, value: undefined })
19
+ })
20
+
21
+ it('should conform an object entry with a buffer operator', () => {
22
+ const entry = SpyneChannelWindow.conformCustomEventConfig({ name: 'my_event', buffer: 400 })
23
+ expect(entry).to.deep.equal({ name: 'my_event', operator: 'buffer', value: 400 })
24
+ })
25
+
26
+ it('should conform debounce, throttle and count operators', () => {
27
+ expect(SpyneChannelWindow.conformCustomEventConfig({ name: 'e1', debounce: 250 }).operator).to.equal('debounce')
28
+ expect(SpyneChannelWindow.conformCustomEventConfig({ name: 'e2', throttle: 100 }).operator).to.equal('throttle')
29
+ expect(SpyneChannelWindow.conformCustomEventConfig({ name: 'e3', count: 50 }).operator).to.equal('count')
30
+ })
31
+
32
+ it('should pick the first operator when several are set', () => {
33
+ const entry = SpyneChannelWindow.conformCustomEventConfig({ name: 'my_event', debounce: 250, buffer: 400 })
34
+ expect(entry.operator).to.equal('buffer')
35
+ expect(entry.value).to.equal(400)
36
+ })
37
+
38
+ it('should skip invalid entries', () => {
39
+ expect(SpyneChannelWindow.conformCustomEventConfig(42)).to.equal(null)
40
+ expect(SpyneChannelWindow.conformCustomEventConfig({ buffer: 400 })).to.equal(null)
41
+ expect(SpyneChannelWindow.conformCustomEventConfig(null)).to.equal(null)
42
+ })
43
+ })
44
+
45
+ describe('window channel customEvents batching operators', () => {
46
+ it('should map a batch to one payload with detail as the details array', () => {
47
+ const batch = [conformedEvent({ id: 1 }), conformedEvent({ id: 2 }), conformedEvent({ id: 3 })]
48
+ const { action, payload, event } = SpyneChannelWindow.mapBatchedEvents(batch)
49
+ expect(action).to.equal('CHANNEL_WINDOW_MY_TEST_EVENT_EVENT')
50
+ expect(payload.detail).to.deep.equal([{ id: 1 }, { id: 2 }, { id: 3 }])
51
+ expect(payload.isBatch).to.be.true
52
+ expect(payload.batchCount).to.equal(3)
53
+ expect(event).to.equal(batch[2].event)
54
+ })
55
+
56
+ it('should close a buffer batch after the quiet gap and emit once', (done) => {
57
+ const source$ = new Subject()
58
+ const obs$ = SpyneChannelWindow.customizeCustomEventObservable(source$, 'buffer', 30)
59
+ const emissions = []
60
+ obs$.subscribe(p => emissions.push(p))
61
+
62
+ source$.next(conformedEvent({ id: 1 }))
63
+ source$.next(conformedEvent({ id: 2 }))
64
+ setTimeout(() => source$.next(conformedEvent({ id: 3 })), 10)
65
+
66
+ setTimeout(() => {
67
+ expect(emissions.length).to.equal(1)
68
+ expect(emissions[0].payload.detail).to.deep.equal([{ id: 1 }, { id: 2 }, { id: 3 }])
69
+ done()
70
+ }, 100)
71
+ })
72
+
73
+ it('should emit separate buffer batches per burst', (done) => {
74
+ const source$ = new Subject()
75
+ const obs$ = SpyneChannelWindow.customizeCustomEventObservable(source$, 'buffer', 20)
76
+ const emissions = []
77
+ obs$.subscribe(p => emissions.push(p))
78
+
79
+ source$.next(conformedEvent({ id: 1 }))
80
+ setTimeout(() => source$.next(conformedEvent({ id: 2 })), 60)
81
+
82
+ setTimeout(() => {
83
+ expect(emissions.length).to.equal(2)
84
+ expect(emissions[0].payload.batchCount).to.equal(1)
85
+ expect(emissions[1].payload.batchCount).to.equal(1)
86
+ done()
87
+ }, 140)
88
+ })
89
+
90
+ it('should batch every n events with the count operator', () => {
91
+ const source$ = new Subject()
92
+ const obs$ = SpyneChannelWindow.customizeCustomEventObservable(source$, 'count', 2)
93
+ const emissions = []
94
+ obs$.subscribe(p => emissions.push(p))
95
+
96
+ source$.next(conformedEvent({ id: 1 }))
97
+ source$.next(conformedEvent({ id: 2 }))
98
+ source$.next(conformedEvent({ id: 3 }))
99
+ source$.next(conformedEvent({ id: 4 }))
100
+ source$.next(conformedEvent({ id: 5 }))
101
+
102
+ expect(emissions.length).to.equal(2)
103
+ expect(emissions[0].payload.detail).to.deep.equal([{ id: 1 }, { id: 2 }])
104
+ expect(emissions[1].payload.detail).to.deep.equal([{ id: 3 }, { id: 4 }])
105
+ })
106
+
107
+ it('should pass only the latest event through debounce', (done) => {
108
+ const source$ = new Subject()
109
+ const obs$ = SpyneChannelWindow.customizeCustomEventObservable(source$, 'debounce', 20)
110
+ const emissions = []
111
+ obs$.subscribe(p => emissions.push(p))
112
+
113
+ source$.next(conformedEvent({ id: 1 }))
114
+ source$.next(conformedEvent({ id: 2 }))
115
+ source$.next(conformedEvent({ id: 3 }))
116
+
117
+ setTimeout(() => {
118
+ expect(emissions.length).to.equal(1)
119
+ // debounced events keep the single-event payload shape (payload = CustomEvent)
120
+ expect(emissions[0].payload.detail).to.deep.equal({ id: 3 })
121
+ done()
122
+ }, 80)
123
+ })
124
+
125
+ it('should return the observable untouched when there is no operator', () => {
126
+ const source$ = new Subject()
127
+ const obs$ = SpyneChannelWindow.customizeCustomEventObservable(source$, undefined, undefined)
128
+ expect(obs$).to.equal(source$)
129
+ })
130
+ })
@@ -1,3 +1,8 @@
1
+ // Silence SpyneJS developer-hint warnings across all test bundles.
2
+ // Expected-failure cases (invalid selectors, blocked URIs, policy removals)
3
+ // would otherwise flood the karma reporter. Security warnings still print.
4
+ window.SPYNE_SUPPRESS_WARNINGS = true
5
+
1
6
  import { SpyneApp } from '../spyne/spyne'
2
7
 
3
8
  // import {AppView} from "../app/app-view";