oracle / oraclejet

Oracle JET is a modular JavaScript Extension Toolkit for developers working on client-side applications.
https://oracle.com/jet
Other
504 stars 115 forks source link

Somewhat Off Topic - But - How To Set Value of OJ Combobox? #73

Open brcolow opened 3 years ago

brcolow commented 3 years ago

Sorry if this is off-topic.

I am attempting to automate input on a website that uses Oracle JET (version 4.1.0).

I am using Selenium to take user-like actions such as entering text in input fields, etc.

All is working well except for trying to set the value of an ojcombobox.

It is defined in the HTML thusly:

<div class="oj-combobox-choice" tabindex="-1" role="presentation" id="oj-combobox-choice-om_shipFromOverride">
   <input type="text" autocomplete="off" autocorrect="off" autocapitalize="none" spellcheck="false" class="oj-combobox-input oj-combobox-focused" role="combobox" aria-expanded="false" aria-autocomplete="list" id="oj-combobox-input-om_shipFromOverride" aria-owns="oj-listbox-results-om_shipFromOverride" aria-labelledby="oj-combobox-label-om_shipFromOverride">
   <abbr class="oj-combobox-clear-entry" role="presentation"></abbr>
   <span class="oj-combobox-divider" role="presentation"></span>
   <a class="oj-combobox-arrow oj-combobox-icon oj-component-icon oj-clickable-icon-nocontext oj-combobox-open-icon" role="button" aria-label="expand">
   </a>
</div>

It is constructed dynamically from a SQL result:

$("#orderBaseManager #om_shipFromOverride").ojCombobox({"options":locations});

I have tried a bunch of things, such as:

 js.executeScript(String.format("arguments[0].value = \"%s\"", SHIP_FROM),
              driver.findElement(By.id("oj-combobox-input-om_shipFromOverride")));

Which sets the value visually, but doesn't seem to actually trigger observables so the script on the page still believes it is not set.

I also tried sending the keys: "TAB, TAB, TAB, TAB... etc. ARROW_DOWN ARROW_DOWN ENTER" but this doesn't seem to do anything.

I am able to execute arbitrary Javascript using Selenium - so I was wondering if there is a way to programmatically make a selection for the combobox? Browsing the documentation I only saw a method for setting the initial value when it is instantiated.

Assistance would be greatly appreciated.

peppertech commented 3 years ago

JET v4.1.0 is very old and it contains security vulnerabilities in multiple libraries that are distributed with JET (jQuery and Knockout to name two). I strongly recommend updating if at all possible.

Updating the value programmatically for oj-combobox is pretty simple in that you just update the observable that you have bound to the "value" property, or you can use setProperty on the component as well.

I'm making an assumption with the above comment, and that is that you are using the custom element syntax <oj-combobox ...> and not the really old jQueryUI syntax which used a Knockout custom binding called ojComponent.

As for test automation, we recommend the use of Selenium Web Driver. We are working on providing a set of Web Elements for the JET components that will make this a lot easier, but they won't be available until later this year unfortunately.

brcolow commented 3 years ago

@peppertech Thanks so much for responding.

I am not the one who created the page. I am the one trying to automate third party input to it :).

I don't know what they used on the backend side of things ( or ojComponent) - I only have access to the front-end Javascript that the browser can see when the page is rendered.

I am using Selenium Web Driver, and that's what I am trying to automate - setting a value on the combobox.

I do see that they are including /js/libs/oj/v4.1.0/min/ojcomponentcore.js so maybe that is the older jquery syntax? Does this make a difference for how to set the value? Do you think you could show me the exact invocation for setting the value? Given that the combobox is instantiated thusly:

$("#orderBaseManager #om_shipFromOverride").ojCombobox({"options":locations});

Also it seems that the backing observable for the combobox is created thusly:

self.shipFromOverride = ko.observable(null);

I also see the following which looks to setup some type of event handler:

$("#om_shipFromOverride").ojCombobox({
    optionChange: function() {self.lookupShipFromRemarks();}
});

Thanks so much.

brcolow commented 3 years ago

@peppertech Do you think you could take a second look? I really appreciate it.

peppertech commented 3 years ago

Hi Michael, sorry for the delay. I've asked one of the test engineers to pop in and give some advice. I'm sure he'll be more knowledgeable than I am on how to use Selenium. From a coding perspective, this link with show you the old API syntax when using jQuery. https://docs.oracle.com/middleware/jet320/jet/reference-jet/oj.ojCombobox.html#event:optionChange

and here is a JET Cookbook demo using that same old jQuery syntax for setting up the component itself in the HTML and then defining the "val" variable as a Knockout observableArray. https://www.oracle.com/webfolder/technetwork/jet-320/jetCookbook.html?component=combobox&demo=single

To update the value, you would set update that observableArray

self.val(['new value'])

brcolow commented 3 years ago

@peppertech Thanks for your feedback.

For some reason self.shipFromOverride is undefined. It seems that observables that are constructed with a null argument don't show up in the self object. Thus I am not able to call self.shipFromOverride.val(['Some Value']).

Again, this is how they setup the combobox - I am not able to change the page itself - only trying to automate it's entry. Is there a way to set the value by going through jQuery as $("#orderBaseManager #om_shipFromOverride") is defined - self.shipFromOverride is not even though it is defined thusly:

self.shipFromOverride = ko.observable(null);

Seems like the null argument is preventing it from being defined. When I use the JS console debugger self.shipFromOverride is undefined - even though it is instantiated as above): Uncaught TypeError: self.shipFromOverride is undefined.

The reason I think the null argument seems to be playing a role is the following observable is instantiated at the same time (next line): self.orderBaseXid = ko.observable(orderBaseRestApiJson.OrderBase.orderBaseXid); and self.orderBaseXid does show up in the self object.

peppertech commented 3 years ago

For the Knockout observable issue, I would try initializing it with an empty string, or just leave it empty (it will default to undefined) and see if that works. I've never seen initializing with null, being and issue before, but the version of Knockout you are working against may very well have a bug in it. It's a bit old and insecure.

peppertech commented 3 years ago

Well, doing a search of the Knockout repository issues, it appears that they did implement a breaking change in Knockout 3.0.0 where you can no longer initialize an observable or observableArray with null or undefined. The version of KO shipped with JET v4.1.0 was 3.4.0. Sooooo, I would initialize with an empty parens or array respectively if you don't know what value you want into there. Empty string will work for observable as well of course. https://github.com/knockout/knockout/pull/1054

itlgit commented 3 years ago

I'm not sure on the DOM structure of the combobox for that version of JET, but have you tried calling sendKeys to the element and then tabbing out? That would normally trigger a "change" event on the element, which should cause the observable value to update. Something like const input = await driver.findElement(By.id('oj-combobox-choice-om_shipFromOverride')) await input.sendKeys(SHIP_FROM, Keys.TAB)

brcolow commented 3 years ago

@itlgit That works in the sense that it enters the text in the combobox, but it does not trigger the following event listener:

        console.log({caller:"optionChange"});
        $("#om_shipFromOverride").ojCombobox({
            optionChange: function() {self.lookupShipFromRemarks();}

        });

and thus the script does not think it is actually filled in and won't let the form be submitted.

itlgit commented 3 years ago

Trying to understand from your sample, which element is id="om_shipFromOverride"? From your sample above, the outer-most "root" element is id="oj-combobox-choice-om_shipFromOverride" and the <input> element is id="oj-combobox-input-om_shipFromOverride"

Since typing the value into the input doesn't seem to trigger optionChange event, I wonder if clicking on the outer element to show the list, then sending it the arrow keys would do it? Or is that what you already tried?

brcolow commented 3 years ago

@itlgit That's a good question. I don't see that in the HTML anywhere, but that's how they reference the combobox. Here are all appearances of that string in their scripts:

    var validationMessages = [];
    if (inspirage.isShipFromOverrideRequired) {
        if (! self.shipFromOverride()) {
        var message = "Ship From Override is required";
        validationMessages.push({message:message, selector:"#om_shipFromOverride"});        
        }

            // set combo box here om_shipFromOverride
            $("#orderBaseManager #om_shipFromOverride").ojCombobox({"options":locations});

    if (false) {
        $("#om_shipFromOverrideDiv").focusout(function() {
            self.lookupShipFromRemarks();
        });
    }

    if (true) {
        console.log({caller:"optionChange"});
        $("#om_shipFromOverride").ojCombobox({
            optionChange: function() {self.lookupShipFromRemarks();}

        });
    }

Indeed, trying to click on the outer element and then sending arrow keys was the first thing I tried.

I realize we may be at an impasse because of how this page was coded - it's quite annoying.

Appreciate all your assistance.

itlgit commented 3 years ago

Does document.querySelector('#om_shipFromOverride') return the root element? If so, can you share the full DOM tree for that element?

brcolow commented 3 years ago

@itlgit It does, good idea.

<div class="oj-flex-item inspirage-writeable">
    <div class="oj-combobox oj-component oj-enabled oj-form-control" id="ojChoiceId_om_shipFromOverride" style="max-width:40em">
        <div class="oj-combobox-choice" tabindex="-1" role="presentation" id="oj-combobox-choice-om_shipFromOverride">
            <input type="text" autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false" class="oj-combobox-input" role="combobox" aria-expanded="false" aria-autocomplete="list" id="oj-combobox-input-om_shipFromOverride" aria-owns="oj-listbox-results-om_shipFromOverride" aria-labelledby="oj-combobox-label-om_shipFromOverride"> <abbr class="oj-combobox-clear-entry" role="presentation"></abbr> <span class="oj-combobox-divider" role="presentation"></span>
            <a class="oj-combobox-arrow oj-combobox-icon oj-component-icon oj-clickable-icon-nocontext oj-combobox-open-icon" role="button" aria-label="expand"></a>
        </div>
        <div class="oj-listbox-drop" style="display:none" role="presentation" data-oj-containerid="ojChoiceId_om_shipFromOverride">
            <ul class="oj-listbox-results" role="listbox" aria-label="Ship From Override" id="oj-listbox-results-om_shipFromOverride"> </ul>
        </div>
        <input id="om_shipFromOverride" data-bind="ojComponent: {component: 'ojCombobox',                     rootAttributes: {style:'max-width:40em'}, value:shipFromOverride}" tabindex="-1" aria-labelledby="oj-combobox-label-om_shipFromOverride" aria-hidden="true" class="oj-component-initnode" style="display: none;">
    </div>
    <!-- <oj-combobox id="om_shipFromOverride" style="max-width:40em" value="[[shipFromOverride]]" on-value-changed="[[lookupShipFromRemarks]]"></oj-combobox> -->
</div>

Is that full enough? Or do you need more DOM?

Thank you so much.

itlgit commented 3 years ago

Sorry for the late reply. Can you share what the live DOM looks like after the component has been created? The original HTML <input id="om_shipFromOverride" data-bind="...> Is the placeholder element where the component will be created, but the real DOM structure will be different after the components is created.

brcolow commented 3 years ago

@itlgit No problem. Thanks for your help.

It seems that the real DOM structure is only created when I interact with the input box (such as typing a character). It does not seem to do it just on page load which may be a problem for automating in this case. But after some interaction here is the result:

I took a screenshot because copy/paste is selecting the old DOM structure.

cvp

itlgit commented 3 years ago

Ok, so #om_shipFromOverride is the original node on which the jQuery component was created, but it's no longer relevant (it's display:none) because, presumably, the component root is now #ojChoiceId_om_shipFromoverride, and the new, effective <input> element is #oj-combobox-input-om_shipFromOverride.

So you've tried send the "value" property via JS, calling sendKeys to send the keystrokes to the input--neither have triggered the optionChange event.

Does the combobox have a expand/drop-down arrow that shows a list of options when clicked? If so, you could try targeting that with your test, click it, then click the appropriate item from the list that displays.

brcolow commented 3 years ago

That's one of the things I tried. It does not seem possible to click on the arrow dropdown box, which happens to be:

<a class="oj-combobox-arrow oj-combobox-icon oj-component-icon oj-clickable-icon-nocontext oj-combobox-open-icon" role="button" aria-label="expand"></a>

I don't mean from Selenium, either. Trying to do it just from the developer tools console also doesn't work:

document.querySelector('#oj-combobox-choice-om_shipFromOverride > a:nth-child(4)').click()

I made sure that the selector is infact selecting the drop down arrow:

cvp2

No matter what I try this continues to stump me.

itlgit commented 3 years ago

Does Selenium give you any error when you try to click it, or it just does nothing? I'm assuming actually clicking it with your mouse works and shows the list...

brcolow commented 3 years ago

Selenium and the Javascript console both do nothing. Actually clicking it with my mouse does work :).