/
select.ts
322 lines (265 loc) · 10.8 KB
/
select.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
import _ from 'lodash'
import Promise from 'bluebird'
import $dom from '../../../dom'
import $utils from '../../../cypress/utils'
import $errUtils from '../../../cypress/error_utils'
import $elements from '../../../dom/elements'
import type { Log } from '../../../cypress/log'
const newLineRe = /\n/g
interface InternalSelectOptions extends Partial<Cypress.SelectOptions> {
_log?: Log
$el: JQuery<HTMLSelectElement>
error?: any
}
export default (Commands, Cypress, cy) => {
Commands.addAll({ prevSubject: 'element' }, {
select (subject, valueOrTextOrIndex, userOptions: Partial<Cypress.SelectOptions> = {}) {
if (
!_.isNumber(valueOrTextOrIndex)
&& !_.isString(valueOrTextOrIndex)
&& !_.isArray(valueOrTextOrIndex)
) {
$errUtils.throwErrByPath('select.invalid_argument', { args: { value: JSON.stringify(valueOrTextOrIndex) } })
}
if (
_.isArray(valueOrTextOrIndex)
&& valueOrTextOrIndex.length > 0
&& !_.some(valueOrTextOrIndex, (val) => _.isNumber(val) || _.isString(val))
) {
$errUtils.throwErrByPath('select.invalid_array_argument', { args: { value: JSON.stringify(valueOrTextOrIndex) } })
}
const options: InternalSelectOptions = _.defaults({}, userOptions, {
$el: subject,
log: true,
force: false,
})
const consoleProps: Record<string, any> = {}
if (options.log) {
// figure out the options which actually change the behavior of clicks
const deltaOptions = $utils.filterOutOptions(options)
options._log = Cypress.log({
message: deltaOptions,
$el: options.$el,
timeout: options.timeout,
consoleProps () {
// merge into consoleProps without mutating it
return _.extend({}, consoleProps, {
'Applied To': $dom.getElements(options.$el),
'Options': deltaOptions,
})
},
})
options._log!.snapshot('before', { next: 'after' })
}
let node
// if subject is a <select> el assume we are filtering down its
// options to a specific option first by value and then by text
// we'll throw if more than one is found AND the select
// element is multiple=multiple
// if the subject isn't a <select> then we'll check to make sure
// this is an option
// if this is multiple=multiple then we'll accept an array of values
// or texts and clear the previous selections which matches jQuery's
// behavior
if (!options.$el.is('select')) {
node = $dom.stringify(options.$el)
$errUtils.throwErrByPath('select.invalid_element', { args: { node } })
}
if (options.$el.length && options.$el.length > 1) {
$errUtils.throwErrByPath('select.multiple_elements', { args: { num: options.$el.length } })
}
// normalize valueOrTextOrIndex if its not an array
valueOrTextOrIndex = [].concat(valueOrTextOrIndex).map((v: any) => {
if (_.isNumber(v) && (!_.isInteger(v) || v < 0)) {
$errUtils.throwErrByPath('select.invalid_number', { args: { index: v } })
}
// https://github.com/cypress-io/cypress/issues/16045
// replace ` ` in the text to `\us00a0` to find match.
// @see https://stackoverflow.com/a/53306311/1038927
return _.isNumber(v) ? v : v.replace(/ /g, '\u00a0')
})
const multiple = options.$el.prop('multiple')
// throw if we're not a multiple select and we've
// passed an array of values
if (!multiple && valueOrTextOrIndex.length > 1) {
$errUtils.throwErrByPath('select.invalid_multiple')
}
const getOptions = () => {
let notAllUniqueValues
// throw if <select> is disabled
if (!options.force && options.$el.prop('disabled')) {
node = $dom.stringify(options.$el)
$errUtils.throwErrByPath('select.disabled', { args: { node } })
}
const values: string[] = []
const optionEls: JQuery<any>[] = []
const optionsObjects = options.$el.find('option').map((index, el) => {
// push the value in values array if its
// found within the valueOrText
const value = $elements.getNativeProp(el, 'value')
const optEl = $dom.wrap(el)
if (valueOrTextOrIndex.includes(value) || valueOrTextOrIndex.includes(index)) {
optionEls.push(optEl)
values.push(value)
}
// replace new line chars, then trim spaces
const trimmedText = optEl.text().replace(newLineRe, '').trim()
// return the elements text + value
return {
value,
originalText: optEl.text(),
text: trimmedText,
$el: optEl,
}
}).get()
// if we couldn't find anything by value then attempt
// to find it by text and insert its value into values arr
if (!values.length) {
// if any of the values are the same and the user is trying to
// select based on the text, setting the value won't work
// `notAllUniqueValues` is used later to do the right thing
const uniqueValues = _.chain(optionsObjects).map('value').uniq().value()
notAllUniqueValues = uniqueValues.length !== optionsObjects.length
_.each(optionsObjects, (obj) => {
if (valueOrTextOrIndex.includes(obj.text)) {
optionEls.push(obj.$el)
const objValue = obj.value
values.push(objValue)
}
})
}
// if we didnt set multiple to true and
// we have more than 1 option to set then blow up
if (!multiple && (values.length > 1)) {
$errUtils.throwErrByPath('select.multiple_matches', {
args: { value: valueOrTextOrIndex.join(', ') },
})
}
if (!values.length && !(_.isArray(valueOrTextOrIndex) && valueOrTextOrIndex.length === 0)) {
$errUtils.throwErrByPath('select.no_matches', {
args: { value: valueOrTextOrIndex.join(', ') },
})
}
_.each(optionEls, ($el) => {
if ($el.prop('disabled')) {
node = $dom.stringify($el)
$errUtils.throwErrByPath('select.option_disabled', {
args: { node },
})
}
})
_.each(optionEls, ($el) => {
if ($el.closest('optgroup').prop('disabled')) {
node = $dom.stringify($el)
$errUtils.throwErrByPath('select.optgroup_disabled', {
args: { node },
})
}
})
return { values, optionEls, optionsObjects, notAllUniqueValues }
}
const retryOptions = () => {
return Promise
.try(getOptions)
.catch((err) => {
options.error = err
return cy.retry(retryOptions, options)
})
}
return Promise
.try(retryOptions)
.then((obj = {}) => {
const { values, optionEls, optionsObjects, notAllUniqueValues } = obj
// preserve the selected values
consoleProps.Selected = values
return cy.now('click', options.$el, {
$el: options.$el,
log: false,
verify: false,
errorOnSelect: false, // prevent click errors since we want the select to be clicked
_log: options._log,
force: options.force,
timeout: options.timeout,
interval: options.interval,
}).then(() => {
// TODO:
// 1. test cancelation
// 2. test passing optionEls to each directly
// 3. update other tests using this Promise.each pattern
// 4. test that force is always true
// 5. test that command is not provided (undefined / null)
// 6. test that option actually receives click event
// 7. test that select still has focus (i think it already does have a test)
// 8. test that multiple=true selects receive option event for each selected option
const activeElement = $elements.getActiveElByDocument(options.$el)
if (!options.force && activeElement === null) {
const node = $dom.stringify(options.$el)
const onFail = options._log
$errUtils.throwErrByPath('select.disabled', {
onFail,
args: { node },
})
}
return Promise
.resolve(optionEls) // why cant we just pass these directly to .each?
.each((optEl) => {
return cy.now('click', optEl, {
$el: optEl,
log: false,
verify: false,
force: true, // always force the click to happen on the <option>
timeout: options.timeout,
interval: options.interval,
})
}).then(() => {
const oldValue = options.$el[0].selectedIndex
// reset the selects value after we've
// fired all the proper click events
// for the options
// TODO: shouldn't we be updating the values
// as we click the <option> instead of
// all afterwards?
options.$el.val(values)
if (notAllUniqueValues) {
// if all the values are the same and the user is trying to
// select based on the text, setting the val() will just
// select the first one
let selectedIndex = 0
_.each(optionEls, ($el) => {
const index = _.findIndex(optionsObjects, (optionObject: any) => {
return $el.text() === optionObject.originalText
})
selectedIndex = index
return $el.prop('selected', 'selected')
})
options.$el[0].selectedIndex = selectedIndex
}
// https://github.com/cypress-io/cypress/issues/19494
// When user selects the same option again, `input`, `change` events should not be fired.
if (options.$el[0].selectedIndex === oldValue) {
return
}
const input = new Event('input', {
bubbles: true,
cancelable: false,
})
options.$el.get(0).dispatchEvent(input)
// yup manually create this change event
// 1.6.5. HTML event types
// scroll down to 'change'
const change = document.createEvent('HTMLEvents')
change.initEvent('change', true, false)
options.$el.get(0).dispatchEvent(change)
})
}).then(() => {
const verifyAssertions = () => {
return cy.verifyUpcomingAssertions(options.$el, options, {
onRetry: verifyAssertions,
})
}
return verifyAssertions()
})
})
},
})
}