wick-dom-observer 1.0.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/src/utils.js ADDED
@@ -0,0 +1,509 @@
1
+ /**
2
+ * Returns true when value is a non-null plain object (and not an array).
3
+ */
4
+ export function isPlainObject(value) {
5
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
6
+ }
7
+
8
+ /**
9
+ * Detects whether a value looks like a Cypress click options object.
10
+ *
11
+ * The check is heuristic and based on known click option keys.
12
+ */
13
+ export function isClickOptions(value) {
14
+ if (!isPlainObject(value)) return false
15
+
16
+ const knownKeys = [
17
+ 'altKey',
18
+ 'animationDistanceThreshold',
19
+ 'button',
20
+ 'cmdKey',
21
+ 'ctrlKey',
22
+ 'force',
23
+ 'log',
24
+ 'metaKey',
25
+ 'multiple',
26
+ 'release',
27
+ 'scrollBehavior',
28
+ 'shiftKey',
29
+ 'timeout',
30
+ 'waitForAnimations',
31
+ ]
32
+
33
+ return Object.keys(value).some((key) => knownKeys.includes(key))
34
+ }
35
+
36
+ /**
37
+ * Returns true when value is a supported Cypress click position string.
38
+ */
39
+ export function isPosition(value) {
40
+ return (
41
+ typeof value === 'string' &&
42
+ [
43
+ 'topLeft',
44
+ 'top',
45
+ 'topRight',
46
+ 'left',
47
+ 'center',
48
+ 'right',
49
+ 'bottomLeft',
50
+ 'bottom',
51
+ 'bottomRight',
52
+ ].includes(value)
53
+ )
54
+ }
55
+
56
+ /**
57
+ * Normalizes optional command arguments into a valid `.click()` signature.
58
+ *
59
+ * Supports:
60
+ * - []
61
+ * - [options]
62
+ * - [position]
63
+ * - [position, options]
64
+ * - [x, y]
65
+ * - [x, y, options]
66
+ *
67
+ * Throws when the shape is invalid.
68
+ */
69
+ export function parseClickArgs(args) {
70
+ if (args.length === 0) return []
71
+
72
+ if (args.length === 1) {
73
+ const [a] = args
74
+
75
+ if (isClickOptions(a)) return [a]
76
+ if (isPosition(a)) return [a]
77
+
78
+ throw new Error(
79
+ 'clickAndWatchForElement(): invalid arguments. Expected click options or a click position.'
80
+ )
81
+ }
82
+
83
+ if (args.length === 2) {
84
+ const [a, b] = args
85
+
86
+ if (isPosition(a) && isClickOptions(b)) return [a, b]
87
+ if (typeof a === 'number' && typeof b === 'number') return [a, b]
88
+
89
+ throw new Error(
90
+ 'clickAndWatchForElement(): invalid arguments. Expected (position, options) or (x, y).'
91
+ )
92
+ }
93
+
94
+ if (args.length === 3) {
95
+ const [a, b, c] = args
96
+
97
+ if (typeof a === 'number' && typeof b === 'number' && isClickOptions(c)) {
98
+ return [a, b, c]
99
+ }
100
+
101
+ throw new Error(
102
+ 'clickAndWatchForElement(): invalid arguments. Expected (x, y, options).'
103
+ )
104
+ }
105
+
106
+ throw new Error(
107
+ 'clickAndWatchForElement(): too many arguments. Supported signatures are clickAndWatchForElement(config), clickAndWatchForElement(config, options), clickAndWatchForElement(config, position), clickAndWatchForElement(config, position, options), clickAndWatchForElement(config, x, y), clickAndWatchForElement(config, x, y, options).'
108
+ )
109
+ }
110
+
111
+ /**
112
+ * Validates element-watch command configuration and throws descriptive errors
113
+ * when required properties are missing or malformed.
114
+ */
115
+ export function validateSpinnerConfig(config) {
116
+ if (!config || typeof config !== 'object') {
117
+ throw new Error('clickAndWatchForElement(): spinner config is required.')
118
+ }
119
+
120
+ if (!config.selector || typeof config.selector !== 'string') {
121
+ throw new Error('clickAndWatchForElement(): config.selector must be a non-empty string.')
122
+ }
123
+
124
+ if (typeof config.assert !== 'function') {
125
+ throw new Error('clickAndWatchForElement(): config.assert must be a function.')
126
+ }
127
+
128
+ if (config.action !== undefined && typeof config.action !== 'function') {
129
+ throw new Error('clickAndWatchForElement(): config.action must be a function when provided.')
130
+ }
131
+
132
+ if (
133
+ config.appear !== undefined &&
134
+ config.appear !== 'optional' &&
135
+ config.appear !== 'required'
136
+ ) {
137
+ throw new Error(
138
+ 'clickAndWatchForElement(): config.appear must be "optional" or "required".'
139
+ )
140
+ }
141
+
142
+ if (config.timeout !== undefined && config.timeout < 0) {
143
+ throw new Error('clickAndWatchForElement(): config.timeout must be >= 0.')
144
+ }
145
+
146
+ if (config.pollingInterval !== undefined && config.pollingInterval <= 0) {
147
+ throw new Error('clickAndWatchForElement(): config.pollingInterval must be > 0.')
148
+ }
149
+
150
+ if (
151
+ config.mustLast !== undefined &&
152
+ (typeof config.mustLast !== 'number' || config.mustLast < 0)
153
+ ) {
154
+ throw new Error(
155
+ 'clickAndWatchForElement(): config.mustLast must be a number >= 0 when provided.'
156
+ )
157
+ }
158
+ }
159
+
160
+ /**
161
+ * Executes assertion callback in "quiet" mode for polling/retry cycles.
162
+ *
163
+ * It suppresses noisy per-retry assertion logs while preserving pass/fail
164
+ * behavior (including support for negated assertions like `.not`).
165
+ */
166
+ function doesAssertionPass(assertFn, $el) {
167
+ if (typeof assertFn !== 'function') return true
168
+
169
+ // Run retry assertions quietly so Cypress does not log every failed retry.
170
+ // This preserves behavior (same pass/fail + timeout), but reduces log noise.
171
+ const originalAssert = chai.Assertion.prototype.assert
172
+ chai.Assertion.prototype.assert = function (expr) {
173
+ const flags = this && this.__flags ? this.__flags : {}
174
+ const negate = Boolean(flags.negate)
175
+ const passed = negate ? !expr : Boolean(expr)
176
+
177
+ if (!passed) {
178
+ throw new Error('assertion failed')
179
+ }
180
+ }
181
+
182
+ try {
183
+ assertFn($el)
184
+ return true
185
+ } catch (_error) {
186
+ return false
187
+ } finally {
188
+ chai.Assertion.prototype.assert = originalAssert
189
+ }
190
+ }
191
+
192
+ /**
193
+ * Ensures a found element remains connected to the DOM for at least `mustLast`.
194
+ *
195
+ * The timing starts at `foundAt` (or now if omitted). Rejects if the element is
196
+ * removed before the minimum duration is reached.
197
+ */
198
+ export function ensureSpinnerLastsAtLeast(
199
+ win,
200
+ node,
201
+ mustLast,
202
+ pollingInterval,
203
+ foundAt
204
+ ) {
205
+ if (mustLast <= 0) return Cypress.Promise.resolve()
206
+
207
+ const appearedAt = foundAt != null ? foundAt : Date.now()
208
+
209
+ return new Cypress.Promise((resolve, reject) => {
210
+ function check() {
211
+ const elapsed = Date.now() - appearedAt
212
+
213
+ if (!node.isConnected) {
214
+ reject(
215
+ new Error(
216
+ `clickAndWatchForElement(): spinner was not visible for the minimum time (mustLast: ${mustLast}ms). It was no longer in the DOM when we checked (${elapsed}ms after it appeared).`
217
+ )
218
+ )
219
+ return
220
+ }
221
+
222
+ if (elapsed >= mustLast) {
223
+ resolve()
224
+ return
225
+ }
226
+
227
+ win.setTimeout(check, Math.min(pollingInterval, mustLast - elapsed))
228
+ }
229
+
230
+ check()
231
+ })
232
+ }
233
+
234
+ /**
235
+ * Waits for an element to appear using a MutationObserver + polling fallback.
236
+ *
237
+ * Resolves as soon as an element matching `selector` exists and passes `assert`.
238
+ * If `mustLastOptions` is provided, starts minimum-duration validation immediately.
239
+ * Rejects on timeout.
240
+ */
241
+ export function waitForSpinnerAppearWithObserver(
242
+ win,
243
+ selector,
244
+ timeout,
245
+ mustLastOptions,
246
+ pollingInterval,
247
+ assert,
248
+ action
249
+ ) {
250
+ return new Cypress.Promise((resolve, reject) => {
251
+ let timeoutId
252
+ let pollId
253
+ let settled = false
254
+
255
+ function cleanup() {
256
+ settled = true
257
+ observer.disconnect()
258
+ if (timeoutId) win.clearTimeout(timeoutId)
259
+ if (pollId) win.clearTimeout(pollId)
260
+ }
261
+
262
+ function findFirstElement() {
263
+ const el = win.document.querySelector(selector)
264
+ if (!el) return null
265
+
266
+ const $el = Cypress.$(el)
267
+ return { el, $el }
268
+ }
269
+
270
+ function tryResolveFromDom() {
271
+ if (settled) return
272
+
273
+ const result = findFirstElement()
274
+ if (!result) return
275
+
276
+ const { el, $el } = result
277
+
278
+ if (!doesAssertionPass(assert, $el)) {
279
+ // Element exists but does not satisfy assertion yet.
280
+ // Keep waiting until it does or until timeout.
281
+ return
282
+ }
283
+
284
+ if (typeof action === 'function') {
285
+ try {
286
+ action($el)
287
+ } catch (error) {
288
+ cleanup()
289
+ reject(error)
290
+ return
291
+ }
292
+ }
293
+
294
+ cleanup()
295
+
296
+ const foundAt = Date.now()
297
+ let mustLastPromise = null
298
+
299
+ if (
300
+ mustLastOptions &&
301
+ mustLastOptions.mustLast != null &&
302
+ mustLastOptions.mustLast > 0
303
+ ) {
304
+ mustLastPromise = ensureSpinnerLastsAtLeast(
305
+ win,
306
+ el,
307
+ mustLastOptions.mustLast,
308
+ mustLastOptions.pollingInterval,
309
+ foundAt
310
+ )
311
+ }
312
+
313
+ resolve({ $el, foundAt, mustLastPromise })
314
+ }
315
+
316
+ function schedulePoll() {
317
+ if (settled) return
318
+ pollId = win.setTimeout(() => {
319
+ tryResolveFromDom()
320
+ schedulePoll()
321
+ }, pollingInterval)
322
+ }
323
+
324
+ const observer = new win.MutationObserver(() => {
325
+ tryResolveFromDom()
326
+ })
327
+
328
+ observer.observe(win.document.body, {
329
+ childList: true,
330
+ subtree: true,
331
+ })
332
+
333
+ // Try immediately in case spinner already exists.
334
+ tryResolveFromDom()
335
+ // Keep retrying assertions even without further DOM mutations.
336
+ schedulePoll()
337
+
338
+ timeoutId = win.setTimeout(() => {
339
+ cleanup()
340
+ reject(
341
+ new Error(
342
+ `clickAndWatchForElement(): spinner "${selector}" did not appear within ${timeout}ms.`
343
+ )
344
+ )
345
+ }, timeout)
346
+ })
347
+ }
348
+
349
+ /**
350
+ * Runs the shared element watch lifecycle used by both commands.
351
+ *
352
+ * Flow:
353
+ * 1) wait for appear (optional or required),
354
+ * 2) optionally enforce `mustLast`,
355
+ * 3) optionally wait for disappearance.
356
+ *
357
+ * Also updates Cypress command logs when provided.
358
+ */
359
+ export function runElementWatchFlow(options) {
360
+ const {
361
+ win,
362
+ selector,
363
+ timeout,
364
+ pollingInterval,
365
+ appear,
366
+ disappear,
367
+ mustLastOptions,
368
+ assert,
369
+ action,
370
+ log,
371
+ } = options
372
+
373
+ const appearPromise = waitForSpinnerAppearWithObserver(
374
+ win,
375
+ selector,
376
+ timeout,
377
+ mustLastOptions,
378
+ pollingInterval,
379
+ assert,
380
+ action
381
+ )
382
+
383
+ return appearPromise
384
+ .catch((error) => {
385
+ if (appear === 'optional') {
386
+ if (log) {
387
+ log.set({
388
+ message: `${selector} not observed (optional)`,
389
+ })
390
+ }
391
+ return null
392
+ }
393
+ throw error
394
+ })
395
+ .then((result) => {
396
+ if (!result) return false
397
+
398
+ const mustLastPromise =
399
+ result && result.mustLastPromise != null
400
+ ? result.mustLastPromise
401
+ : null
402
+
403
+ if (mustLastPromise) {
404
+ return mustLastPromise.then(() => true)
405
+ }
406
+
407
+ return true
408
+ })
409
+ .then((wasObserved) => {
410
+ if (!wasObserved) return null
411
+
412
+ if (log) {
413
+ log.set({ message: `${selector} observed` })
414
+ }
415
+
416
+ if (!disappear) return null
417
+
418
+ return pollForSpinnerState({
419
+ win,
420
+ selector,
421
+ timeout,
422
+ pollingInterval,
423
+ mode: 'disappear',
424
+ }).then(() => {
425
+ if (log) {
426
+ log.set({ message: `${selector} observed and disappeared` })
427
+ }
428
+ expect(true, `${selector} disappeared`).to.be.true
429
+ })
430
+ })
431
+ }
432
+
433
+ /**
434
+ * Polls for element state transitions.
435
+ *
436
+ * - `mode: 'appear'` waits for first matching element to exist (and optionally
437
+ * satisfy `assert`).
438
+ * - `mode: 'disappear'` waits until no matching visible element remains.
439
+ *
440
+ * Rejects when timeout is reached.
441
+ */
442
+ export function pollForSpinnerState(options) {
443
+ const {
444
+ win,
445
+ selector,
446
+ timeout,
447
+ pollingInterval,
448
+ mode,
449
+ assert,
450
+ } = options
451
+
452
+ const startedAt = Date.now()
453
+
454
+ return new Cypress.Promise((resolve, reject) => {
455
+ function check() {
456
+ const el = win.document.querySelector(selector)
457
+ const elapsed = Date.now() - startedAt
458
+
459
+ if (mode === 'appear') {
460
+ if (el) {
461
+ const $el = Cypress.$(el)
462
+
463
+ try {
464
+ if (assert) assert($el)
465
+ resolve($el)
466
+ return
467
+ } catch (error) {
468
+ // Spinner exists but assertion is not satisfied yet.
469
+ // Keep polling until timeout.
470
+ }
471
+ }
472
+
473
+ if (elapsed >= timeout) {
474
+ reject(
475
+ new Error(
476
+ `clickAndWatchForElement(): spinner "${selector}" did not appear and satisfy assertions within ${timeout}ms.`
477
+ )
478
+ )
479
+ return
480
+ }
481
+
482
+ win.setTimeout(check, pollingInterval)
483
+ return
484
+ }
485
+
486
+ const matchingElements = Array.from(win.document.querySelectorAll(selector))
487
+ const hasVisibleElement = matchingElements.some((node) => Cypress.dom.isVisible(node))
488
+
489
+ // Treat spinner as disappeared when no matching element exists OR all are hidden.
490
+ if (matchingElements.length === 0 || !hasVisibleElement) {
491
+ resolve(null)
492
+ return
493
+ }
494
+
495
+ if (elapsed >= timeout) {
496
+ reject(
497
+ new Error(
498
+ `clickAndWatchForElement(): spinner "${selector}" did not disappear (removed or hidden) within ${timeout}ms.`
499
+ )
500
+ )
501
+ return
502
+ }
503
+
504
+ win.setTimeout(check, pollingInterval)
505
+ }
506
+
507
+ check()
508
+ })
509
+ }
package/src/watcher.js ADDED
@@ -0,0 +1,178 @@
1
+ import * as utils from './utils'
2
+
3
+ /**
4
+ * Ensures `expect(subject).to.be.visible()` works consistently for subjects used
5
+ * inside this plugin (jQuery collections, native nodes, or nullable values).
6
+ *
7
+ * The command assertion callbacks receive sync values (not Cypress chains),
8
+ * so we normalize the subject to a jQuery collection and evaluate visibility via
9
+ * `Cypress.dom.isVisible()` for every matched element.
10
+ */
11
+ function ensureVisibleAssertionSupport() {
12
+ chai.Assertion.addChainableMethod(
13
+ 'visible',
14
+ function () {
15
+ const subject = this._obj
16
+ const $elements =
17
+ subject && subject.jquery
18
+ ? subject
19
+ : subject != null
20
+ ? Cypress.$(subject)
21
+ : Cypress.$()
22
+
23
+ const isVisible =
24
+ $elements.length > 0 &&
25
+ $elements.toArray().every((el) => Cypress.dom.isVisible(el))
26
+
27
+ this.assert(
28
+ isVisible,
29
+ 'expected #{this} to be visible',
30
+ 'expected #{this} not to be visible'
31
+ )
32
+ },
33
+ function () { }
34
+ )
35
+ }
36
+
37
+ ensureVisibleAssertionSupport()
38
+
39
+
40
+ // -----------------------------------------------------------------------
41
+ // CUSTOM COMMAND clickAndWatchForElement
42
+ // -----------------------------------------------------------------------
43
+
44
+ /**
45
+ * Clicks the target subject and watches for the configured element lifecycle.
46
+ *
47
+ * The command starts observing before click to reliably catch fast elements,
48
+ * then applies the same watch flow rules (appear optional/required, optional
49
+ * minimum duration, and optional disappearance check).
50
+ */
51
+
52
+ Cypress.Commands.add(
53
+ 'clickAndWatchForElement',
54
+ { prevSubject: 'element' },
55
+ (subject, config, ...rawArgs) => {
56
+ utils.validateSpinnerConfig(config)
57
+
58
+ const clickArgs = utils.parseClickArgs(rawArgs)
59
+ const timeout =
60
+ config.timeout != null
61
+ ? config.timeout
62
+ : Cypress.config('defaultCommandTimeout')
63
+ const pollingInterval =
64
+ config.pollingInterval != null ? config.pollingInterval : 10
65
+ const appear = config.appear || 'optional'
66
+ const disappear = config.disappear || false
67
+ const mustLast = config.mustLast
68
+
69
+ const log = Cypress.log({
70
+ name: 'clickAndWatchForElement',
71
+ message: config.selector,
72
+ consoleProps() {
73
+ return {
74
+ selector: config.selector,
75
+ timeout,
76
+ pollingInterval,
77
+ appear,
78
+ disappear,
79
+ mustLast,
80
+ clickArgs,
81
+ }
82
+ },
83
+ })
84
+
85
+ const mustLastOptions =
86
+ appear === 'required' && mustLast != null && mustLast > 0
87
+ ? { mustLast, pollingInterval }
88
+ : null
89
+
90
+ return cy.window({ log: false, timeout }).then((win) => {
91
+ // Start observing before click to catch very short-lived elements.
92
+ const watchFlowPromise = utils.runElementWatchFlow({
93
+ win,
94
+ selector: config.selector,
95
+ timeout,
96
+ pollingInterval,
97
+ appear,
98
+ disappear,
99
+ mustLastOptions,
100
+ assert: config.assert,
101
+ action: config.action,
102
+ log,
103
+ })
104
+
105
+ return cy
106
+ .wrap(subject, { log: false, timeout })
107
+ .click(...clickArgs)
108
+ .then({ timeout }, () => watchFlowPromise)
109
+ .then(() => cy.wrap(subject, { log: false, timeout }))
110
+ })
111
+ }
112
+ )
113
+
114
+
115
+ // -----------------------------------------------------------------------
116
+ // CUSTOM COMMAND watchForElement
117
+ // -----------------------------------------------------------------------
118
+
119
+ /**
120
+ * Watches for the configured element lifecycle without any click action.
121
+ *
122
+ * Use this when the element may appear automatically (for example on page
123
+ * load, delayed startup banners, or background-triggered signals).
124
+ */
125
+ Cypress.Commands.add('watchForElement', (config) => {
126
+ utils.validateSpinnerConfig(config)
127
+
128
+ const timeout =
129
+ config.timeout != null
130
+ ? config.timeout
131
+ : Cypress.config('defaultCommandTimeout')
132
+ const pollingInterval =
133
+ config.pollingInterval != null ? config.pollingInterval : 10
134
+ const appear = config.appear || 'optional'
135
+ const disappear = config.disappear || false
136
+ const mustLast = config.mustLast
137
+
138
+ const log = Cypress.log({
139
+ name: 'watchForElement',
140
+ message: config.selector,
141
+ consoleProps() {
142
+ return {
143
+ selector: config.selector,
144
+ timeout,
145
+ pollingInterval,
146
+ appear,
147
+ disappear,
148
+ mustLast,
149
+ }
150
+ },
151
+ })
152
+
153
+ const mustLastOptions =
154
+ appear === 'required' && mustLast != null && mustLast > 0
155
+ ? { mustLast, pollingInterval }
156
+ : null
157
+
158
+ return cy.window({ log: false, timeout }).then((win) => {
159
+ const watchFlowPromise = utils.runElementWatchFlow({
160
+ win,
161
+ selector: config.selector,
162
+ timeout,
163
+ pollingInterval,
164
+ appear,
165
+ disappear,
166
+ mustLastOptions,
167
+ assert: config.assert,
168
+ action: config.action,
169
+ log,
170
+ })
171
+
172
+ return cy
173
+ .wrap(null, { log: false, timeout })
174
+ .then({ timeout }, () => watchFlowPromise)
175
+ .then(() => cy.wrap(null, { log: false, timeout }))
176
+ })
177
+ })
178
+