1
0
mirror of https://github.com/MontFerret/ferret.git synced 2025-09-16 09:06:36 +02:00

Feature/#177 hover (#178)

This commit is contained in:
Tim Voronov
2018-11-15 14:33:53 -05:00
committed by GitHub
parent 64a28cdebb
commit c6a4ede15d
17 changed files with 550 additions and 111 deletions

View File

@@ -4,7 +4,8 @@
#### Added
- DateTime functions.
- ``PAGINATION`` function.
- ``SCROLL_TOP`` and ``SCROLL_BOTTOM`` functions.
- ``SCROLL_TOP``, ``SCROLL_BOTTOM`` and ``SCROLL_ELEMENT`` functions.
- ``HOVER`` function.
#### Fixed
- Unable to define variables and make function calls before FILTER, SORT and etc statements.

View File

@@ -1,6 +1,7 @@
import Layout from './layout.js';
import IndexPage from './pages/index.js';
import FormsPage from './pages/forms/index.js';
import EventsPage from './pages/events/index.js';
const e = React.createElement;
const Router = ReactRouter.Router;
@@ -21,7 +22,11 @@ export default function AppComponent({ redirect = null}) {
e(Route, {
path: '/forms',
component: FormsPage
})
}),
e(Route, {
path: '/events',
component: EventsPage
}),
]),
redirect ? e(Redirect, { to: redirect }) : null
])

View File

@@ -0,0 +1,52 @@
const e = React.createElement;
export default class HoverableComponent extends React.PureComponent {
constructor(props) {
super(props);
this.state = {
hovered: false
};
}
handleMouseEnter() {
this.setState({
hovered: true
});
}
handleMouseLeave() {
this.setState({
hovered: false
});
}
render() {
const children = [];
children.push(
e("p", null, [
e("a", {
id: "hoverable-btn",
className: "btn btn-primary",
href: "#",
onMouseEnter: this.handleMouseEnter.bind(this),
onMouseLeave: this.handleMouseLeave.bind(this)
}, [
"Hoverable link"
]),
])
);
if (this.state.hovered) {
children.push(
e("div", null, [
e("div", { id: "hoverable-content", className: "card card-body"}, [
"Lorem ipsum dolor sit amet."
])
])
)
}
return e("div", null, children);
}
}

View File

@@ -0,0 +1,13 @@
import Hoverable from "./hoverable.js";
const e = React.createElement;
export default class EventsPage extends React.Component {
render() {
return e("div", { className: "row", id: "page-events" }, [
e("div", { className: "col-lg-12"}, [
e(Hoverable)
])
])
}
}

View File

@@ -45,7 +45,7 @@ export default class FormsPage extends React.Component {
}
render() {
return e("form", null, [
return e("form", { id: "page-form" }, [
e("div", { className: "form-group" }, [
e("label", null, "Text input"),
e("input", {

11
e2e/tests/doc_hover_d.fql Normal file
View File

@@ -0,0 +1,11 @@
LET url = @dynamic + "?redirect=/events"
LET doc = DOCUMENT(url, true)
WAIT_ELEMENT(doc, "#page-events")
HOVER(doc, "#hoverable-btn")
WAIT_ELEMENT(doc, "#hoverable-content")
LET output = INNER_TEXT(doc, "#hoverable-content")
RETURN EXPECT(output, "Lorem ipsum dolor sit amet.")

13
e2e/tests/el_hover_d.fql Normal file
View File

@@ -0,0 +1,13 @@
LET url = @dynamic + "?redirect=/events"
LET doc = DOCUMENT(url, true)
WAIT_ELEMENT(doc, "#page-events")
LET input = ELEMENT(doc, "#hoverable-btn")
HOVER(input)
WAIT_ELEMENT(doc, "#hoverable-content")
LET output = ELEMENT(doc, "#hoverable-content")
RETURN EXPECT(output.innerText, "Lorem ipsum dolor sit amet.")

View File

@@ -3,6 +3,7 @@ package dynamic
import (
"context"
"fmt"
"github.com/mafredri/cdp/protocol/dom"
"hash/fnv"
"sync"
"time"
@@ -531,6 +532,45 @@ func (doc *HTMLDocument) SelectBySelector(selector values.String, value *values.
return nil, core.TypeError(core.ArrayType, res.Type())
}
func (doc *HTMLDocument) HoverBySelector(selector values.String) error {
ctx, cancel := contextWithTimeout()
defer cancel()
err := doc.ScrollBySelector(selector)
if err != nil {
return err
}
selectorArgs := dom.NewQuerySelectorArgs(doc.element.id.nodeID, selector.String())
found, err := doc.client.DOM.QuerySelector(ctx, selectorArgs)
if err != nil {
doc.element.logError(err).
Str("selector", selector.String()).
Msg("failed to retrieve a node by selector")
return err
}
if found.NodeID <= 0 {
return errors.New("element not found")
}
q, err := getClickablePoint(ctx, doc.client, &HTMLElementIdentity{
nodeID: found.NodeID,
})
if err != nil {
return err
}
return doc.client.Input.DispatchMouseEvent(
ctx,
input.NewDispatchMouseEventArgs("mouseMoved", q.X, q.Y),
)
}
func (doc *HTMLDocument) WaitForSelector(selector values.String, timeout values.Int) error {
task := events.NewEvalWaitTask(
doc.client,
@@ -815,7 +855,7 @@ func (doc *HTMLDocument) ScrollTop() error {
window.scrollTo({
left: 0,
top: 0,
behavior: 'smooth'
behavior: 'instant'
});
`, false, false)
@@ -827,13 +867,32 @@ func (doc *HTMLDocument) ScrollBottom() error {
window.scrollTo({
left: 0,
top: window.document.body.scrollHeight,
behavior: 'smooth'
behavior: 'instant'
});
`, false, false)
return err
}
func (doc *HTMLDocument) ScrollBySelector(selector values.String) error {
_, err := eval.Eval(doc.client, fmt.Sprintf(`
var el = document.querySelector(%s);
if (el == null) {
throw new Error("element not found");
}
el.scrollIntoView({
behavior: 'instant'
});
return true;
`, eval.ParamString(selector.String()),
), false, false)
return err
}
func (doc *HTMLDocument) handlePageLoad(_ interface{}) {
doc.Lock()
defer doc.Unlock()

View File

@@ -27,8 +27,6 @@ const DefaultTimeout = time.Second * 30
var emptyNodeID = dom.NodeID(0)
var emptyBackendID = dom.BackendNodeID(0)
var emptyObjectID = ""
var attrID = "data-ferret-id"
type (
HTMLElementIdentity struct {
@@ -439,36 +437,6 @@ func (el *HTMLElement) QuerySelectorAll(selector values.String) core.Value {
return arr
}
func (el *HTMLElement) WaitForClass(class values.String, timeout values.Int) error {
task := events.NewWaitTask(
func() (core.Value, error) {
current := el.GetAttribute("class")
if current.Type() != core.StringType {
return values.None, nil
}
str := current.(values.String)
classStr := string(class)
classes := strings.Split(string(str), " ")
for _, c := range classes {
if c == classStr {
return values.True, nil
}
}
return values.None, nil
},
time.Millisecond*time.Duration(timeout),
events.DefaultPolling,
)
_, err := task.Run()
return err
}
func (el *HTMLElement) InnerText() values.String {
val, err := el.innerText.Read()
@@ -707,6 +675,36 @@ func (el *HTMLElement) CountBySelector(selector values.String) values.Int {
return values.NewInt(len(res.NodeIDs))
}
func (el *HTMLElement) WaitForClass(class values.String, timeout values.Int) error {
task := events.NewWaitTask(
func() (core.Value, error) {
current := el.GetAttribute("class")
if current.Type() != core.StringType {
return values.None, nil
}
str := current.(values.String)
classStr := string(class)
classes := strings.Split(string(str), " ")
for _, c := range classes {
if c == classStr {
return values.True, nil
}
}
return values.None, nil
},
time.Millisecond*time.Duration(timeout),
events.DefaultPolling,
)
_, err := task.Run()
return err
}
func (el *HTMLElement) Click() (values.Boolean, error) {
ctx, cancel := contextWithTimeout()
@@ -749,6 +747,8 @@ func (el *HTMLElement) Input(value core.Value, delay values.Int) error {
}
func (el *HTMLElement) Select(value *values.Array) (*values.Array, error) {
var attrID = "data-ferret-select"
if el.NodeName() != "SELECT" {
return nil, core.Error(core.ErrInvalidOperation, "Element is not a <select> element.")
}
@@ -807,6 +807,8 @@ func (el *HTMLElement) Select(value *values.Array) (*values.Array, error) {
false,
)
el.client.DOM.RemoveAttribute(ctx, dom.NewRemoveAttributeArgs(el.id.nodeID, attrID))
if err != nil {
return nil, err
}
@@ -820,6 +822,68 @@ func (el *HTMLElement) Select(value *values.Array) (*values.Array, error) {
return nil, core.TypeError(core.ArrayType, res.Type())
}
func (el *HTMLElement) ScrollIntoView() error {
var attrID = "data-ferret-scroll"
id, err := uuid.NewV4()
if err != nil {
return err
}
ctx, cancel := contextWithTimeout()
defer cancel()
err = el.client.DOM.SetAttributeValue(ctx, dom.NewSetAttributeValueArgs(el.id.nodeID, attrID, id.String()))
if err != nil {
return err
}
_, err = eval.Eval(el.client, fmt.Sprintf(`
var el = document.querySelector('[%s="%s"]');
if (el == null) {
throw new Error('element not found');
}
el.scrollIntoView({
behavior: 'instant',
inline: 'center',
block: 'center'
});
`,
attrID,
id.String(),
), false, false)
el.client.DOM.RemoveAttribute(ctx, dom.NewRemoveAttributeArgs(el.id.nodeID, attrID))
return err
}
func (el *HTMLElement) Hover() error {
err := el.ScrollIntoView()
if err != nil {
return err
}
ctx, cancel := contextWithTimeout()
defer cancel()
q, err := getClickablePoint(ctx, el.client, el.id)
if err != nil {
return err
}
return el.client.Input.DispatchMouseEvent(
ctx,
input.NewDispatchMouseEventArgs("mouseMoved", q.X, q.Y),
)
}
func (el *HTMLElement) IsConnected() values.Boolean {
el.mu.Lock()
defer el.mu.Unlock()

View File

@@ -14,10 +14,18 @@ import (
"github.com/mafredri/cdp/protocol/page"
"github.com/mafredri/cdp/protocol/runtime"
"golang.org/x/sync/errgroup"
"math"
"strings"
)
type batchFunc = func() error
type (
batchFunc = func() error
Quad struct {
X float64
Y float64
}
)
func runBatch(funcs ...batchFunc) error {
eg := errgroup.Group{}
@@ -39,6 +47,90 @@ func getRootElement(ctx context.Context, client *cdp.Client) (*dom.GetDocumentRe
return d, nil
}
func fromProtocolQuad(quad dom.Quad) []Quad {
return []Quad{
{
X: quad[0],
Y: quad[1],
},
{
X: quad[2],
Y: quad[3],
},
{
X: quad[4],
Y: quad[5],
},
{
X: quad[6],
Y: quad[7],
},
}
}
func computeQuadArea(quads []Quad) float64 {
var area float64
for i := range quads {
p1 := quads[i]
p2 := quads[(i+1)%len(quads)]
area += (p1.X*p2.Y - p2.X*p1.Y) / 2
}
return math.Abs(area)
}
func getClickablePoint(ctx context.Context, client *cdp.Client, id *HTMLElementIdentity) (Quad, error) {
qargs := dom.NewGetContentQuadsArgs()
if id.objectID != "" {
qargs.SetObjectID(id.objectID)
} else if id.backendID != 0 {
qargs.SetBackendNodeID(id.backendID)
} else {
qargs.SetNodeID(id.nodeID)
}
res, err := client.DOM.GetContentQuads(ctx, qargs)
if err != nil {
return Quad{}, err
}
if res.Quads == nil || len(res.Quads) == 0 {
return Quad{}, errors.New("node is either not visible or not an HTMLElement")
}
quads := make([][]Quad, 0, len(res.Quads))
for _, q := range res.Quads {
quad := fromProtocolQuad(q)
if computeQuadArea(quad) > 1 {
quads = append(quads, quad)
}
}
if len(quads) == 0 {
return Quad{}, errors.New("node is either not visible or not an HTMLElement")
}
// Return the middle point of the first quad.
quad := quads[0]
var x float64
var y float64
for _, q := range quad {
x += q.X
y += q.Y
}
return Quad{
X: x / 4,
Y: y / 4,
}, nil
}
func parseAttrs(attrs []string) *values.Object {
var attr values.String

55
pkg/stdlib/html/hover.go Normal file
View File

@@ -0,0 +1,55 @@
package html
import (
"context"
"github.com/MontFerret/ferret/pkg/html/dynamic"
"github.com/MontFerret/ferret/pkg/runtime/core"
"github.com/MontFerret/ferret/pkg/runtime/values"
)
// Hover fetches an element with selector, scrolls it into view if needed, and then uses page.mouse to hover over the center of the element.
// If there's no element matching selector, the method returns an error.
// @param docOrEl (HTMLDocument|HTMLElement) - Target document or element.
// @param selector (String, options) - If document is passed, this param must represent an element selector.
func Hover(_ context.Context, args ...core.Value) (core.Value, error) {
err := core.ValidateArgs(args, 1, 2)
if err != nil {
return values.None, err
}
// document or element
err = core.ValidateType(args[0], core.HTMLDocumentType, core.HTMLElementType)
if err != nil {
return values.None, err
}
if len(args) == 2 {
err = core.ValidateType(args[1], core.StringType)
if err != nil {
return values.None, err
}
// Document with a selector
doc, ok := args[0].(*dynamic.HTMLDocument)
if !ok {
return values.None, core.Errors(core.ErrInvalidType, ErrNotDynamic)
}
selector := args[1].(values.String)
return values.None, doc.HoverBySelector(selector)
}
// Element
el, ok := args[0].(*dynamic.HTMLElement)
if !ok {
return values.None, core.Errors(core.ErrInvalidType, ErrNotDynamic)
}
return values.None, el.Hover()
}

View File

@@ -13,31 +13,33 @@ var (
func NewLib() map[string]core.Function {
return map[string]core.Function{
"CLICK": Click,
"CLICK_ALL": ClickAll,
"DOCUMENT": Document,
"DOCUMENT_PARSE": DocumentParse,
"DOWNLOAD": Download,
"ELEMENT": Element,
"ELEMENTS": Elements,
"ELEMENTS_COUNT": ElementsCount,
"WAIT_ELEMENT": WaitElement,
"WAIT_NAVIGATION": WaitNavigation,
"WAIT_CLASS": WaitClass,
"WAIT_CLASS_ALL": WaitClassAll,
"CLICK": Click,
"CLICK_ALL": ClickAll,
"NAVIGATE": Navigate,
"NAVIGATE_BACK": NavigateBack,
"NAVIGATE_FORWARD": NavigateForward,
"INPUT": Input,
"HOVER": Hover,
"INNER_HTML": InnerHTML,
"INNER_HTML_ALL": InnerHTMLAll,
"INNER_TEXT": InnerText,
"INNER_TEXT_ALL": InnerTextAll,
"SELECT": Select,
"SCREENSHOT": Screenshot,
"SCROLL_TOP": ScrollTop,
"SCROLL_BOTTOM": ScrollBottom,
"INPUT": Input,
"NAVIGATE": Navigate,
"NAVIGATE_BACK": NavigateBack,
"NAVIGATE_FORWARD": NavigateForward,
"PAGINATION": Pagination,
"PDF": PDF,
"DOWNLOAD": Download,
"SCREENSHOT": Screenshot,
"SCROLL_BOTTOM": ScrollBottom,
"SCROLL_ELEMENT": ScrollInto,
"SCROLL_TOP": ScrollTop,
"SELECT": Select,
"WAIT_ELEMENT": WaitElement,
"WAIT_CLASS": WaitClass,
"WAIT_CLASS_ALL": WaitClassAll,
"WAIT_NAVIGATION": WaitNavigation,
}
}

View File

@@ -1,56 +0,0 @@
package html
import (
"context"
"github.com/MontFerret/ferret/pkg/html/dynamic"
"github.com/MontFerret/ferret/pkg/runtime/core"
"github.com/MontFerret/ferret/pkg/runtime/values"
)
// ScrollTop Scrolls the document's window to its top.
// @param doc (Document) - Target document.
func ScrollTop(_ context.Context, args ...core.Value) (core.Value, error) {
err := core.ValidateArgs(args, 1, 1)
if err != nil {
return values.None, err
}
err = core.ValidateType(args[0], core.HTMLDocumentType)
if err != nil {
return values.None, err
}
doc, ok := args[0].(*dynamic.HTMLDocument)
if !ok {
return values.None, core.Errors(core.ErrInvalidType, ErrNotDynamic)
}
return values.None, doc.ScrollTop()
}
// ScrollTop Scrolls the document's window to its bottom.
// @param doc (Document) - Target document.
func ScrollBottom(_ context.Context, args ...core.Value) (core.Value, error) {
err := core.ValidateArgs(args, 1, 1)
if err != nil {
return values.None, err
}
err = core.ValidateType(args[0], core.HTMLDocumentType)
if err != nil {
return values.None, err
}
doc, ok := args[0].(*dynamic.HTMLDocument)
if !ok {
return values.None, core.Errors(core.ErrInvalidType, ErrNotDynamic)
}
return values.None, doc.ScrollBottom()
}

View File

@@ -0,0 +1,32 @@
package html
import (
"context"
"github.com/MontFerret/ferret/pkg/html/dynamic"
"github.com/MontFerret/ferret/pkg/runtime/core"
"github.com/MontFerret/ferret/pkg/runtime/values"
)
// ScrollTop scrolls the document's window to its bottom.
// @param doc (HTMLDocument) - Target document.
func ScrollBottom(_ context.Context, args ...core.Value) (core.Value, error) {
err := core.ValidateArgs(args, 1, 1)
if err != nil {
return values.None, err
}
err = core.ValidateType(args[0], core.HTMLDocumentType)
if err != nil {
return values.None, err
}
doc, ok := args[0].(*dynamic.HTMLDocument)
if !ok {
return values.None, core.Errors(core.ErrInvalidType, ErrNotDynamic)
}
return values.None, doc.ScrollBottom()
}

View File

@@ -0,0 +1,54 @@
package html
import (
"context"
"github.com/MontFerret/ferret/pkg/html/dynamic"
"github.com/MontFerret/ferret/pkg/runtime/core"
"github.com/MontFerret/ferret/pkg/runtime/values"
)
// ScrollInto scrolls an element on.
// @param docOrEl (HTMLDocument|HTMLElement) - Target document or element.
// @param selector (String, options) - If document is passed, this param must represent an element selector.
func ScrollInto(_ context.Context, args ...core.Value) (core.Value, error) {
err := core.ValidateArgs(args, 1, 2)
if err != nil {
return values.None, err
}
// document or element
err = core.ValidateType(args[0], core.HTMLDocumentType, core.HTMLElementType)
if err != nil {
return values.None, err
}
if len(args) == 2 {
err = core.ValidateType(args[1], core.StringType)
if err != nil {
return values.None, err
}
// Document with a selector
doc, ok := args[0].(*dynamic.HTMLDocument)
if !ok {
return values.None, core.Errors(core.ErrInvalidType, ErrNotDynamic)
}
selector := args[1].(values.String)
return values.None, doc.ScrollBySelector(selector)
}
// Element
el, ok := args[0].(*dynamic.HTMLElement)
if !ok {
return values.None, core.Errors(core.ErrInvalidType, ErrNotDynamic)
}
return values.None, el.ScrollIntoView()
}

View File

@@ -0,0 +1,32 @@
package html
import (
"context"
"github.com/MontFerret/ferret/pkg/html/dynamic"
"github.com/MontFerret/ferret/pkg/runtime/core"
"github.com/MontFerret/ferret/pkg/runtime/values"
)
// ScrollTop scrolls the document's window to its top.
// @param doc (HTMLDocument) - Target document.
func ScrollTop(_ context.Context, args ...core.Value) (core.Value, error) {
err := core.ValidateArgs(args, 1, 1)
if err != nil {
return values.None, err
}
err = core.ValidateType(args[0], core.HTMLDocumentType)
if err != nil {
return values.None, err
}
doc, ok := args[0].(*dynamic.HTMLDocument)
if !ok {
return values.None, core.Errors(core.ErrInvalidType, ErrNotDynamic)
}
return values.None, doc.ScrollTop()
}

View File

@@ -50,7 +50,12 @@ func WaitClass(_ context.Context, args ...core.Value) (core.Value, error) {
return values.None, err
}
doc := args[0].(*dynamic.HTMLDocument)
doc, ok := args[0].(*dynamic.HTMLDocument)
if !ok {
return values.None, core.Errors(core.ErrInvalidType, ErrNotDynamic)
}
selector := args[1].(values.String)
class := args[2].(values.String)
@@ -66,7 +71,12 @@ func WaitClass(_ context.Context, args ...core.Value) (core.Value, error) {
return values.None, doc.WaitForClass(selector, class, timeout)
case *dynamic.HTMLElement:
el := args[0].(*dynamic.HTMLElement)
el, ok := args[0].(*dynamic.HTMLElement)
if !ok {
return values.None, core.Errors(core.ErrInvalidType, ErrNotDynamic)
}
class := args[1].(values.String)
if len(args) == 3 {