-
Notifications
You must be signed in to change notification settings - Fork 9.3k
throw an error upon page.select option not present #1099
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Conversation
|
Thanks for your pull request. It looks like this may be your first contribution to a Google open source project. Before we can look at your pull request, you'll need to sign a Contributor License Agreement (CLA). 📝 Please visit https://cla.developers.google.com/ to sign. Once you've signed, please reply here (e.g.
|
|
I signed it! |
|
CLAs look good, thanks! |
|
Could you please add a test? @alixaxel, any concerns about this PR? |
|
@aslushnikov since we can handle the error based on the message, I don't see any downsides. I would probably replace |
|
I added a test @aslushnikov . I'm unfamiliar with writing tests so you should probably go over what I wrote, but it passed. I did not delete the original failing tests with my most recent commit. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@AlexChung1995 The logic is reversed and needs to be fixed, also some linting is in order.
Check out CONTRIBUTING.md, you will want to run:
npm run lint
npm run doc
You should also update the API docs, to document the newly added error.
https://github.com/GoogleChrome/puppeteer/blob/master/docs/api.md#pageselectselector-values
lib/Page.js
Outdated
| throw new Error('Option does not exist'); | ||
| } | ||
| if (!options.some(function(v) {return values.indexOf(v.value) >= 0;})) throw new Error('Option does not exist.'); | ||
|
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@AlexChung1995 You need to get rid of these trailing spaces.
lib/Page.js
Outdated
| if (!options.some(function (v) {return values.indexOf(v.value) >= 0;})){ | ||
| throw new Error('Option does not exist'); | ||
| } | ||
| if (!options.some(function(v) {return values.indexOf(v.value) >= 0;})) throw new Error('Option does not exist.'); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
You could write a more concise/readable version using arrow functions and Array.includes:
if (!options.some(option => values.includes(option.value)))
throw new Error('Option does not exist.');But I'm still not sure of the correctness of this logic, I will comment shortly.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Correct me if I'm wrong, but I read this code as follows:
For every <select> option, try to find a corresponding entry in provided `values`.
If any of the options can't be matched, throw.
This is fundamentally different from what it should be doing:
For every provided `values`, try to find a corresponding entry in <select> options.
If any of the `values` can't be matched, throw.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think the logic is equivalent. The code just attempts to match some value to some option in both cases. I think for readability, your version makes more sense with the expected error output so I will change it.
| })); | ||
|
|
||
|
|
||
|
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Getting rid of these 2 extra empty lines would be nice.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Ok, should I remove the old tests that fail now?
… removed tests that do not fit the new behaviour
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@AlexChung1995 I have a question: now that we enforce values to exist on populated options, how are we able to deselect potentially previously selected options? Before, sending any non-existing value would have this effect, but now that functionality is gone.
Do you have any suggestions? I'm thinking that having special semantics for null or false would be a way to achieve the same.
lib/Page.js
Outdated
|
|
||
| const options = Array.from(element.options); | ||
|
|
||
| if (!values.some(value => options.some(option => option.value === value))) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think this needs to use values.every() instead of values.some() to handle variadic inputs.
Maybe also an additional test would be helpful, since it's not so trivial to reason about.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I suppose it would depend on the intended behaviour. I was more concerned about whether at least one change is made to the select element. With values.every(), the behaviour becomes concerned about whether all intended changes are made. If we use values.every() we would be required, I think, to include a list of options that were not selected with the error message.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I agree with having a special semantic for null would be a good way to include the old functionality of deselecting options. If I figure out a better way I will implement it.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'm of the opinion that, if we are doing these checks, then they should have the same semantics for one or several elements. Or better yet, follow the existing heuristic: if the select has the multiple attribute set, check all provided values exists within the options, otherwise only check the first.
If your goal is to only know how many options were changed/selected, I would prefer a different approach where we don't throw and instead just return an int for the total number of selections made.
Perhaps @aslushnikov can weight in on this, since it's quite subjective.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think the function can be considered to fail if it does not change the select element so would throw an error. That was why I thought throwing an error might be a good idea.
A return value is also a good solution.If the function was to return a value, perhaps an array of all selected elements would be the best. For the deselected functionality, the function could then return an empty array.
I find the heuristic of, on a select element without the multiple property set to true, assigning the element the first value in the provided array kind of counter intuitive. I could see two cases where a developer might provide multiple values to a select element that takes on only one value.
1.) the developer believes the select element can actually take on multiple values. If you believe this case is how the function will be used, I think it should throw an error because the developers intended usage is not what will actually happen.
2.) the developer provides multiple values, some of which may not be options, and wants the select element to set the first value that is an option.
What do you think? @aslushnikov @alixaxel
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thanks @AlexChung1995 @alixaxel, I like the API.
Is there any way I can deselect all options with this API?
docs/api.md
Outdated
|
|
||
| Triggers a `change` and `input` event once all the provided options have been selected. | ||
| If there's no `<select>` element matching `selector`, the method throws an error. | ||
| If no value in the provided values array matches any `<option>` element, the method throws an error. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
do i understand correctly, that you now return all the selected options instead of throwing an error? can we add this to the doc?
| */ | ||
| async select(selector, ...values) { | ||
| await this.$eval(selector, (element, values) => { | ||
| return await this.$eval(selector, (element, values) => { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
nit: let's update jsdoc's return
lib/Page.js
Outdated
| } | ||
| element.dispatchEvent(new Event('input', { 'bubbles': true })); | ||
| element.dispatchEvent(new Event('change', { 'bubbles': true })); | ||
| return Promise.all(filteredValues); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Promise.all doesn't make sense to me here: there are no promises inside the array
lib/Page.js
Outdated
| option.selected = values.includes(option.value); | ||
| } else { | ||
| element.value = values.shift(); | ||
| filteredValues.splice(1); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
if we want to return values that are selected, i'd prefer generating them from options.
// ...
return options.filter(option => option.selected).map(option => option.value);
test/test.js
Outdated
| })); | ||
|
|
||
| it('should work with no options', SX(async function() { | ||
| it('should throw', SX(async function() { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
nit: please elaborate when exactly it should throw
|
@aslushnikov @alixaxel I updated the docs, added some tests, and made some small changes. This solution retains the old functionality where you can clear selected values by inputting nothing to the values parameter. |
lib/Page.js
Outdated
| if (element.multiple) { | ||
| for (const option of options) | ||
| option.selected = values.includes(option.value); | ||
| option.selected = filteredValues.includes(option.value); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I don't understand why you need filteredValues. This line can be rewritten as the following:
option.selected = values.includes(option.value);
lib/Page.js
Outdated
| option.selected = filteredValues.includes(option.value); | ||
| } else { | ||
| element.value = values.shift(); | ||
| element.value = filteredValues[0]; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
... and this line can be just
// explicitly assign `undefined` to state that passing in empty array resets selection.
element.value = values.length ? values[0] : undefined;There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I thought it would be nice if the function would select the first value in values that also exists in options. I can do it in one loop like this if youd prefer.
element.value = undefined;
for (var i = values.length - 1; i>=0; i--){
for (var option of options){
if (option.value === values[i]){
option.selected = true;
break;
}
}
}
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I thought it would be nice if the function would select the first value in values that also exists in options. I can do it in one loop like this if youd prefer.
Can you please clarify why do you want this? I don't think this is of much importance, so I'd rather have code as simple as possible.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I thought that was the only way it would make sense to pass multiple values to this function when the found select element only takes one value. You could create an array of values, order them by priority, and the function would select the value that exists with highest priority.
Otherwise, I think passing multiple values into this function when the select element only takes one value is a logical flaw. When someone just sets the select element to be the first value, if there is no such value in options it deselects everything. So you would have asked the function to select multiple options, but it actually deselects every option.
It does increase code complexity, so I understand if you disagree.
async select(selector, ...values) {
return await this.$eval(selector, (element, values) => {
if (element.nodeName.toLowerCase() !== 'select')
throw new Error('Element is not a <select> element.');
const options = Array.from(element.options);
element.value = undefined;
for (let i = values.length - 1; i >= 0; i--){
for (const option of options){
if (option.value === values[i]){
option.selected = true;
break;
}
}
}
element.dispatchEvent(new Event('input', { 'bubbles': true }));
element.dispatchEvent(new Event('change', { 'bubbles': true }));
return options.filter(option => option.selected).map(option => option.value);
}, values);
}
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@AlexChung1995 Wouldn't the following achieve the same effect you're proposing?
async select(selector, ...values) {
return await this.$eval(selector, (element, values) => {
if (element.nodeName.toLowerCase() !== 'select')
throw new Error('Element is not a <select> element.');
const options = Array.from(element.options);
for (const option of options) {
option.selected = values.includes(option.value);
if ((option.selected === true) && (element.multiple === false)) {
break;
}
}
element.dispatchEvent(new Event('input', { 'bubbles': true }));
element.dispatchEvent(new Event('change', { 'bubbles': true }));
return options.filter(option => option.selected).map(option => option.value);
}, values);
}There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I tried that implementation and realized that it prioritizes by the order of the options and not the order of the values. I tried iterating from the start of the values array but thought the code was ugly. For example:
element.value = undefined;
for (const value of values) {
const option = options.find(option => option.value === value);
if (option){
option.selected = true;
if (!element.multiple)
break;
}
}
If you let me know which of the 3 versions you would prefer (original, prioritize by value, prioritize by option), Ill update the code and commit so we can wrap up this pull request!
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@AlexChung1995 thanks, I understand now your intentions here.
Otherwise, I think passing multiple values into this function when the select element only takes one value is a logical flaw.
I agree. We shouldn't encourage this, so I'd avoid giving any special treatment to the multiple values passed in for a non-multiple select.
Given this, I like the second suggestion with dropped if-statement:
async select(selector, ...values) {
return await this.$eval(selector, (element, values) => {
if (element.nodeName.toLowerCase() !== 'select')
throw new Error('Element is not a <select> element.');
const options = Array.from(element.options);
for (const option of options)
option.selected = values.includes(option.value);
element.dispatchEvent(new Event('input', { 'bubbles': true }));
element.dispatchEvent(new Event('change', { 'bubbles': true }));
return options.filter(option => option.selected).map(option => option.value);
}, values);
}It's dummy, which is nice.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Ok, Ill do another commit. Since this is your solution @alixaxel I assume you're ok with it?
Thanks @alixaxel @aslushnikov for your help.
…cs are still good
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Build failed but not due to my changes. I guess my version of puppeteer and the tests is not completely up to date.
| } else { | ||
| element.value = values.shift(); | ||
| } | ||
| element.value = undefined; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
nit: do we need this?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yeah, it fails tests otherwise.
| */ | ||
| async select(selector, ...values) { | ||
| await this.$eval(selector, (element, values) => { | ||
| return await this.$eval(selector, (element, values) => { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
nit: can you please update jsdoc on line 775? it should have the "return" statement
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Like this?
- returns: <[Promise]<[Array]<[string]>>> Returns an array of option values that have been successfully selected.
I put that in there but its on line 928. Line 775 in my api.md file refers to Page.goForward().
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I mean the jsdoc for this function (that block comment with @param tags inside)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
oh i see
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
where can i find documentation about the !, ? and | modifiers for jsdoc? I dont know what they do.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@AlexChung1995 indeed. Briefly speaking, the "!" is a non-nullable type, the "?" is a nullable type. More info could be found here: https://github.com/google/closure-compiler/wiki/Annotating-JavaScript-for-the-Closure-Compiler
|
@AlexChung1995 thanks! I've addressed a nit here, I hope you don't mind. I'll land this as soon as CI cycles. |
For my use case, I needed to know when page.select succeeded to alter the form vs when page.select failed. A case where page.select fails is when the option is not present. I think this use case may be common and it could be a good addition to puppeteer! This solution may be too case specific though as page.select may fail under other conditions.