sourceafBounce::Element.fan

using concurrent
using afSizzle
using afButter
using xml

** (HTML Element) Represents a generic HTML element.
const class Element {
    private const ElemFinder finder
    
    new makeFromCss(Str cssSelector) {
        this.finder = FindFromSizzleThreadLocal(|->SizzleDoc| { sizzleDoc }, cssSelector) 
    }
    
    @NoDoc
    new makeFromFinder(ElemFinder elemFinder) {
        this.finder = elemFinder 
    }

    
    
    // ---- Standard Methods -------------------------------------------------------------------------------------------
    
    ** Returns the name of the the element. e.g. 'div'
    ** 
    ** The method 'name()' is reserved for the 'name' attribute of form inputs.
    Str elementName() {
        findElem.name
    }

    ** Returns the 'id' attribute as declared by the element. Returns 'null' if the element does not have an 'id' attribute.
    Str? id() {
        getAttr("id")
    }

    ** Returns the 'class' attribute as declared by the element, otherwise 'null'.
    Str? cssClass() {
        getAttr("class")
    }
    @NoDoc @Deprecated { msg="Use 'cssClass()' instead" }
    Str? classs() { cssClass }
    
    ** Returns 'true' if the class attribute contains the given value. 
    ** 
    ** The match is done on a whitespace split of the class attribute and is case insensitive.
    Bool hasCssClass(Str value) {
        getAttr("class")?.lower?.split?.contains(value.trim.lower) ?: false
    }
    @NoDoc @Deprecated { msg="Use 'hasCssClass()' instead" }
    Bool hasClass(Str value) { hasCssClass(value) }
    
    ** Returns 'true' if the element defines the given attribute, regardless of its value. 
    Bool hasAttr(Str value) {
        getAttr(value) != null
    }
    
    ** Returns 'true' if this element exists.
    Bool exists() {
        !findElems.isEmpty
    }
    
    ** Returns the text content of this element and it's child elements.
    Str text() {
        getText(findElem)
    }

    ** Returns the markup generated by this node, including the element itself. 
    Str html() {
        getHtml(findElem)
    }

    ** Returns the markup generated by the children of this node. 
    Str innerHtml() {
        getInnerHtml(findElem)
    }

    ** Returns the value of the named attribute. Returns 'null' if it does not exist.
    ** 
    ** Example using the operator shortcut:
    ** 
    **   attrVal := element["attrName"]
    @Operator
    Str? getAttr(Str name) {
        findElem.attr(name, false)?.val
    }

    ** Returns the element of the current selection at the specified index. Use -1 to select from the end of the list.
    ** 
    ** Example using the operator shortcut:
    ** 
    **   value := element[-2]
    ** 
    ** Note this method is *safe* and does NOT throw an Err should the index be out of bounds. 
    ** (Although subsequent method calls on the returned object would fail.)
    ** Instead use 'verifyDoesNotExist()'.
    ** 
    ** Also note that this returns different results to the CSS selector ':nth-child'.  
    @Operator
    This getAtIndex(Int index) {
        newElementAtIndex(index)
    }

    ** Returns the number of elements found by the selector
    Int size() {
        findElems.size
    }

    ** Finds elements *inside* this element.
    Element find(Str cssSelector) {
        newElementFromCss(cssSelector)      
    }
    
    ** Return all elements as a list.
    Element[] list() {
        findElems.map |elem, i| { newElementAtIndex(i) }
    }
    
    ** Returns the first 'XElem' object. 
    ** 
    ** Returns 'null' if 'checked' is 'false' and no elements are found.
    ** Always throws an Err is multiple elements are returned.
    XElem? xelem(Bool checked := true) {
        elems := findElems
        if (elems.isEmpty && checked)
            fail("CSS does not exist: ", true)
        if (elems.size > 1)
            fail("CSS returned multiple elements: ", false)
        return elems.first
    }

    ** Returns a list of underlying 'XElem' objects. The list may be empty.
    XElem[] xelems() {
        findElems
    }
    
    ** Submits an enclosing form to Bed App.
    virtual ButterResponse submitForm() {
        submitEnclosingForm
    }
    

    
    // ---- Verify Methods ---------------------------------------------------------------------------------------------
    
    ** Verify that at least one element is selected from the document, otherwise throw a test failure exception.
    Void verifyExists() {
        verifyTrue(exists, "CSS does NOT exist: ")
    }
    
    ** Verify that the current selection heralds no elements, otherwise throw a test failure exception.
    Void verifyDoesNotExist() {
        verifyTrue(!exists, "CSS DOES exist: ")
    }
    
    ** Verify that the given text matches the text of the element. The match is case insensitive. 
    Void verifyTextEq(Obj expected) {
        verifyEq(text, expected)
    }

    ** Verify that the element text contains the given str. The match is case insensitive. 
    Void verifyTextContains(Obj contains) {
        verifyTrue(text.trim.lower.contains(contains.toStr.trim.lower), "Text does NOT contain '${contains}': ")
    }
    
    ** Verify that the element has the given attribute value. 
    Void verifyAttrEq(Str attrName, Obj expected) {
        verifyTrue(findElem.attr(attrName, false) != null, "Attribute '${attrName}' does NOT exist: ")
        verifyEq(findElem.attr(attrName).val, expected)
    }
    
    ** Verify that the element defines the given attribute, regardless of value. 
    Void verifyAttrExists(Str attrName) {
        verifyTrue(findElem.attr(attrName, false) != null, "Attribute '${attrName}' does NOT exist: ")
    }
    
    ** Verify that the current selection has the given size. 
    Void verifySizeEq(Int expectedSize) {
        verifyEq(size.toStr, expectedSize)
    }

    ** Verify that the current selection has the given size. 
    Void verifyCssClassContains(Obj expected) {
        attrName := "class"
        verifyTrue(findElem.attr(attrName, false) != null, "Attribute '${attrName}' does NOT exist: ")
        verifyTrue(hasCssClass(expected.toStr), "Class attribute does NOT exist: ")
    }
    @NoDoc @Deprecated { msg="Use 'verifyCssClassContains()' instead" }
    Void verifyClassContains(Obj expected) { verifyCssClassContains(expected) }

    
    
    // ---- Conversion Methods -----------------------------------------------------------------------------------------
    
    ** Returns this element as a `CheckBox`
    CheckBox toCheckBox() {
        CheckBox(finder)
    }
    
    ** Returns this element as a `RadioButton` input.
    RadioButton toRadioButton() {
        RadioButton(finder)
    }
    
    ** Returns this element as a `Hidden` input
    Hidden toHidden() {
        Hidden(finder)
    }
    
    ** Returns this element as a `Link`
    Link toLink() {
        Link(finder)
    }
    
    ** Returns this element as an `Option`
    Option toOption() {
        Option(finder)
    }
    
    ** Returns this element as a `SelectBox`
    SelectBox toSelectBox() {
        SelectBox(finder)
    }

    ** Returns this element as a `SubmitButton`
    SubmitButton toSubmitButton() {
        SubmitButton(finder)
    }

    ** Returns this element as a `TextBox`
    TextBox toTextBox() {
        TextBox(finder)
    }

    ** Returns this element as a `FormInput`
    FormInput toFormInput() {
        FormInput(finder)
    }
    
    
    
    // ---- Common Verify Methods --------------------------------------------------------------------------------------

    @NoDoc
    protected Void verifyTrue(Bool condition, Str msg) {
        testInstance.verify(condition, msg + toStr)
    }
    
    @NoDoc
    protected Void verifyEq(Str actual, Obj expected) {
        if (actual.trim.lower != expected.toStr.trim.lower)
            testInstance.verifyEq(actual.trim, expected.toStr.trim)
    }

    ** Returns Obj? so it may be in-lined as a return value
    @NoDoc
    protected Obj? fail(Str msg, Bool showFullPageHtml) {
        if (showFullPageHtml) {
            testInstance.fail(msg + toStr + "\n" + sizzleDoc.rootElement.writeToStr)
        } else
            testInstance.fail(msg + toStr)
        return null
    }
    

    
    // ---- Helper Methods ---------------------------------------------------------------------------------------------

    @NoDoc
    virtual protected XElem findElem() {
        elems := findElems
        if (elems.isEmpty)
            fail("CSS does not exist: ", true)
        if (elems.size > 1)
            fail("CSS returned multiple elements: ", false)
        return elems.first
    }

    @NoDoc
    virtual protected XElem[] findElems() {
        finder.findElems
    }

    @NoDoc
    virtual protected BedClient bedClient() {
        BedClient.getThreadedClient
    }

    @NoDoc
    virtual protected SizzleDoc sizzleDoc() {
        Actor.locals["afBounce.sizzleDoc"] ?: bedClient.sizzleDoc
    }

    private Void processInput(Str:Str values, XElem elem, |Attr attr->Str?| func) {
        attr := Attr(elem)
        // don't submit values of disabled inputs
        if (attr["disabled"] != null)
            return
        val := func.call(attr)
        if (val != null) {
            // only care about the name if we need to submit the value
            name := attr["name"]
            if (name == null)
                Pod.of(this).log.warn("Form element has NO name: " + getHtml(elem))
            else
                values[name] = val
        }
    }
    
    @NoDoc
    virtual protected ButterResponse submitEnclosingForm(XElem? submitElem := null) {
        files   := Str[,]
        values  := [Str:Str][:] { caseInsensitive = true }
        form    := SizzleDoc(findForm)
        
        form.select("textarea").each |elem| {
            processInput(values, elem) |attr->Str?| {
                return getText(elem)
            }
        }

        form.select("select").each |elem| {
            processInput(values, elem) |attr->Str?| {
                options := SizzleDoc(elem).select("option[selected]")
                return (options.isEmpty) ? null : Attr(options.first)["value"]
            }
        }

        form.select("button").each |elem| {
            processInput(values, elem) |attr->Str?| {
                type := attr["type"]?.trim?.lower
                if (type == "submit" && elem == submitElem)
                    return attr["value"]
                return null
            }
        }

        form.select("input").each |elem| {
            processInput(values, elem) |attr->Str?| {
                type := attr["type"]?.trim?.lower ?: "text"
    
                // only the value of the 'clicked' submit button is sent to the server
                if ((type == "submit" || type == "image") && elem != submitElem)
                    return null
    
                if (type == "checkbox")
                    return (attr["checked"] == null) ? null : "on" 
    
                if (type == "radio")
                    return (attr["checked"] == null) ? null : (attr["value"] ?: "") 
    
                if (type == "file") {
                    if (attr["value"]?.trimToNull == null)
                        return null
                    files.add(attr["name"])
                }
                
                return attr["value"] ?: ""
            }
        }
        
        formAttrs   := Attr(form.rootElement) 
        submitAttrs := (submitElem != null) ? Attr(submitElem) : null
        
        // add submit value if it was added via a 'form' attr
        if (submitAttrs != null)
            if (submitAttrs["disabled"] == null && submitAttrs["name"] != null) {
                nom := submitAttrs["name"]
                val := submitAttrs["value"]
                values[nom] = val ?: ""
            }
        
        action := formAttrs["action"] ?: Str.defVal
        if (action.toStr.isEmpty)
            action = bedClient.lastRequest?.url?.encode ?: Str.defVal
        
        if (submitAttrs?.has("formaction") ?: false)
            action = submitAttrs["formaction"]
        
        if (action.toStr.isEmpty)
            fail("Form has no 'action' attribute: ", false)

        request := ButterRequest(Uri.decode(action))

        method  := formAttrs["method"]?.trim
        if (submitAttrs?.has("formmethod") ?: false)
            method = submitAttrs["formmethod"]?.trim
        
        encType := formAttrs["enctype"]
        if (submitAttrs?.has("formenctype") ?: false)
            encType = submitAttrs["formenctype"]
        
        if (method != null)
            request.method = method

        // favour setting the enctype rather than not
        if (encType == null && request.method != "GET")
            encType = "application/x-www-form-urlencoded"
        
        if (encType != null)
            request.headers.contentType = MimeType(encType)
        
        if (request.method == "GET")
            request.url = request.url.plusQuery(values)
        
        else if (request.method == "POST") {
            if (encType.lower == "multipart/form-data") {
                request.writeMultipartForm |mform| {
                    values.each |val, nom| {
                        if (files.contains(nom))
                            mform.writeFile(nom, File.os(val))
                        else
                            mform.writeText(nom, val)
                    }
                }
            } else
                request.body.str = Uri.encodeQuery(values)
        } else 
            throw Err("Form method attribute should be GET or POST only: ${request.method}")
        
        // v1.0.18 BugFix: Forms should always have the content-length set
        if (encType != null && request.headers.contentLength != null)
            request.headers.contentLength = request.body.size

        // form submits should have the referrer set
        request.headers.referrer = bedClient.lastRequest?.url
        
        return bedClient.sendRequest(request)
    }

    @NoDoc
    virtual protected XElem findForm(XElem elem := findElem) {
        if (elem.name.equalsIgnoreCase("form"))
            return elem
        
        // check for the HTML form attr
        formId := elem.get("form", false)
        if (formId != null)
            return SizzleDoc(elem.doc).select("#${formId}").first ?: fail("Form '${formId}' does not exist: ", true)
        
        if (elem.parent is XElem)   // we can end up with null or an XDoc if we search too high
            return findForm(elem.parent)
        return fail("Could not find an enclosing <form> for element ", true)
    }
    
    ** Sets the attribute. A value of 'null' removes it.
    @NoDoc
    virtual protected Void setAttr(Str name, Str? value, XElem elem := findElem) {
        Attr(elem)[name] = value
    }

    
    
    // ---- Private Methods --------------------------------------------------------------------------------------------

    private This newElementAtIndex(Int index) {
        (Element) typeof.method(#makeFromFinder.name, true).call(finder.clone(FindAtIndex(index)))
    }

    private Element newElementFromCss(Str cssSelector) {
        Element(finder.clone(FindFromCss(cssSelector)))
    }
    
    private Str getHtml(XElem elem) {
        elem.writeToStr
    }

    private Str getInnerHtml(XElem elem) {
        elem.children.map |XNode node->Str| { node.writeToStr }.join
    }

    private Str getText(XNode node) {
        if (node is XText)
            return ((XText) node).val
        if (node is XElem)
            return ((XElem) node).children.map { getText(it) }.join
        return Str.defVal
    }

    private Test testInstance() {
        // a small (external) hook so Test classes can notch up extra verify counts. 
        testInstance := Actor.locals["afBounce.testInstance"]
        return (testInstance != null && testInstance is Test) ? testInstance : Verify()
    }
    
    ** Returns the complete CSS selector and the resulting HTML.
    override Str toStr() {
        return finder.toStr + "\n" + findElems.map { getHtml(it) }.join("\n")
    }
}

internal class Attr {
    XElem? elem
    new make(XElem? elem) {
        this.elem = elem
    }
    @Operator
    Str? getAttr(Str name) {
        find(name)?.val
    }
    @Operator
    Void set(Str name, Str? value) {
        attr := find(name)
        if (attr != null)
            elem.removeAttr(attr)
        if (value != null) 
            elem.addAttr(name, value)
    }
    Bool has(Str name) {
        find(name) != null
    }
    Str name() {
        elem.name.trim.lower
    }
    private XAttr? find(Str name) {
        elem.attrs.find { it.name.equalsIgnoreCase(name) }
    }
}

internal class Verify : Test {}