Skip to content
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

test: Use real mouse events in tests on Firefox #20889

Merged
merged 10 commits into from
Aug 20, 2024
2 changes: 0 additions & 2 deletions pkg/storaged/storage-controls.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -120,7 +120,6 @@ export const StorageButton = ({ id, kind, excuse, onClick, children, ariaLabel,
onClick={checked(onClick, setSpinning)}
variant={kind || "secondary"}
isDisabled={!!excuse || (spinner && spinning)}
style={excuse ? { pointerEvents: 'none' } : null}
isLoading={spinner ? spinning : undefined}>
{children}
</Button>
Expand All @@ -131,7 +130,6 @@ export const StorageLink = ({ id, excuse, onClick, children }) => (
<StorageControl excuse={excuse}
content={excuse => (
<Button onClick={checked(onClick)}
style={excuse ? { pointerEvents: 'none' } : null}
variant="link"
isInline
isDisabled={!!excuse}>
Expand Down
13 changes: 13 additions & 0 deletions test/common/test-functions.js
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,19 @@ window.ph_find = function(sel) {
return window.ph_only(els, sel);
};

window.ph_find_scroll_into_view = function(sel) {
const el = window.ph_find(sel);
/* we cannot make this conditional, as there is no way to find out whether
an element is currently visible -- the usual trick to compare getBoundingClientRect() against
window.innerHeight does not work if the element is in e.g. a scrollable dialog area */
return new Promise(resolve => {
el.scrollIntoView({ behaviour: 'instant', block: 'center', inline: 'center' });
// scrolling needs a little bit of time to stabilize, and it's not predictable
// in particular, 'scrollend' is not reliably emitted
window.setTimeout(() => resolve(el), 200);
});
};

window.ph_count = function(sel) {
const els = window.ph_select(sel);
return els.length;
Expand Down
58 changes: 57 additions & 1 deletion test/common/testlib.py
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,7 @@
"ArrowDown": "\uE015",
"Insert": "\uE016",
"Delete": "\uE017",
"Meta": "\uE03D",
}


Expand Down Expand Up @@ -502,7 +503,62 @@ def mouse(
:param metaKey: press the meta key
"""
self.wait_visible(selector)
self.call_js_func('ph_mouse', selector, event, x, y, btn, ctrlKey, shiftKey, altKey, metaKey)

# HACK: Chromium clicks don't work with iframes; use our old "synthesize MouseEvent" approach
# https://issues.chromium.org/issues/359616812
# TODO: x and y are not currently implemented: webdriver (0, 0) is the element's center, not top left corner
if self.browser == "chromium" or x != 0 or y != 0:
Copy link
Member

@jelly jelly Aug 19, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I kinda lean to making the X/Y API more logical in the future:

test/verify/check-storage-lvm2:            b.mouse(slider, "click", width, 0)
test/verify/check-storage-partitions:        b.mouse(slider, "click", about_half_way, 0)

So this usually relates to the thing you want to click. So once the chromium bug is resolved I'd be for changing this to the BiDi API as well and making it the x/y coordinate relative to the clicked element (this feels very logical).

Copy link
Member Author

@martinpitt martinpitt Aug 19, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The x/y coords are relative to the clicked element of course -- but for MouseEvent, i.e. via ph_mouse(), (0, 0) is the top left, while with webdriver (0, 0) is the element's center.

Once we can run chromium with BiDi as well, then indeed I'm all for just redefining x/y here, and adjusting tests to specify the delta relative to the center. That's just impractical with this "half pm_mouse/half BiDi" state.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Then we are on the same page 👍

self.call_js_func('ph_mouse', selector, event, x, y, btn, ctrlKey, shiftKey, altKey, metaKey)
return

# For Firefox and regular clicks, use the BiDi API, which is more realistic -- it doesn't
# sidestep the browser
element = self.call_js_func('ph_find_scroll_into_view', selector)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So this gives us a 200ms penalty per click, isn't that quite noticeable when running the tests?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also we don't always have to scroll into view I assume? If the element is visible we don't have to scroll do we?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Right -- most tests should be fine without scrolling, but some (like the services or logs page) need it. There is https://developer.mozilla.org/en-US/docs/Web/API/Element/scrollIntoViewIfNeeded , but it's only implemented in Chromium. But this would not actually help with having to wait for it.

The extra time isn't noticeable when you look at how long a test runs. Most of the BiDi churn is actually waiting for stuff (and our ph_wait_cond() is super inefficient, plus we have a lot of redundancy in our abstract API (like calling wait_present multiple times).

I'm not proud of this, but I tried different approaches and nothing else was reliable 😢

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

scrollIntoViewIfneeded sounds nice, should we add a future note about using that?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We already use it for pixel tests. But it would only give us a minor optimization (page jumping around less if you look at it in an interactive browser). The problem is knowing when it's done, and *IfNeeded() doesn't help us there in the slightest.

What I'd need would be some query if the element is currently visible. I didn't find one a few days ago, but I can do some more experiments and look harder. Then we can do this conditionally.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I experimented with .getBoundingClientRect() a bit in an interactive browser, on the hwinfo page:

  • If the element is visible, the x/y coordinates are positive.
  • If the element scrolled off to the top, y aka. top is negative.
  • If the element is scrolled off to the bottom, y is very large, e.g. "3600". We could compare .bottom to window.innerHeight. Like here

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I tried this approach in commit b0c1084 but it causes several failures. None of them fail due to "move target out of bounds" any more, so that proves that the approach generally works -- the extra sleep just papered over more bugs in our tests.

I'll try to track them down.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Some failures are shallow (and I'll commit the fixes anyway), but this failure shows why this bounding rect approach doesn't work: Here the element is in a scrollable dialog pane, and hidden by defaullt. You have to scroll the dialog, not the page, to make it visible. This failure is robust (can be run interactively, with sleeps and sits).

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I didn't find a way around that, and scrollIntoViewIfNeeded() wouldn't help us either as it does not have a return value. So this was an interesting experiment, but failed 😢 Back to the original.


actions = [{"type": "pointerMove", "x": 0, "y": 0, "origin": {"type": "element", "element": element}}]
down = {"type": "pointerDown", "button": btn}
up = {"type": "pointerUp", "button": btn}
if event == "click":
actions.extend([down, up])
elif event == "dblclick":
actions.extend([down, up, down, up])
elif event == "mouseenter":
actions.insert(0, {"type": "pointerMove", "x": 0, "y": 0, "origin": "viewport"})
elif event == "mouseleave":
actions.append({"type": "pointerMove", "x": 0, "y": 0, "origin": "viewport"})
else:
raise NotImplementedError(f"unknown event {event}")

# modifier keys
ev_id = f"pointer-{self.driver.last_id}"
keys_pre = []
keys_post = []

def key(type_: str, name: str) -> JsonObject:
return {"type": "key", "id": ev_id + type_, "actions": [{"type": type_, "value": WEBDRIVER_KEYS[name]}]}

if altKey:
keys_pre.append(key("keyDown", "Alt"))
keys_post.append(key("keyUp", "Alt"))
if ctrlKey:
keys_pre.append(key("keyDown", "Control"))
keys_post.append(key("keyUp", "Control"))
if shiftKey:
keys_pre.append(key("keyDown", "Shift"))
keys_post.append(key("keyUp", "Shift"))
if metaKey:
keys_pre.append(key("keyDown", "Meta"))
keys_post.append(key("keyUp", "Meta"))

# the actual mouse event
actions = [{
"id": ev_id,
"type": "pointer",
"parameters": {"pointerType": "mouse"},
"actions": actions,
}]

self.bidi("input.performActions", context=self.driver.context, actions=keys_pre + actions + keys_post)

def click(self, selector: str) -> None:
"""Click on a ui element
Expand Down
1 change: 1 addition & 0 deletions test/verify/check-apps
Original file line number Diff line number Diff line change
Expand Up @@ -247,6 +247,7 @@ class TestApps(packagelib.PackageCase):
b.wait_visible('#display-language-modal')
b.click(f'#display-language-modal li[data-value={lang}] button')
b.click("#display-language-modal footer button.pf-m-primary")
b.wait_not_present('#display-language-modal')
b.wait_language(lang)
b.enter_page("/apps")

Expand Down
3 changes: 1 addition & 2 deletions test/verify/check-networkmanager-firewall
Original file line number Diff line number Diff line change
Expand Up @@ -336,8 +336,7 @@ class TestFirewall(netlib.NetworkCase):
m.execute("firewall-cmd --add-service=pop3")
b.wait_visible(".zone-section[data-id='public'] tr[data-row-id='pop3']")
b.wait_visible(".zone-section[data-id='public'] td:contains('Additional ports') + td:contains('9998')")
# remove service via cli and cockpit
m.execute("firewall-cmd --remove-service=pop3")
# remove service in cockpit
b.click(".zone-section[data-id='public'] tr[data-row-id='pop3'] button.pf-v5-c-menu-toggle")
b.click(".zone-section[data-id='public'] .pf-v5-c-menu__list-item.pf-m-danger button")
b.wait_not_present(".zone-section[data-id='public'] tr[data-row-id='pop3']")
Expand Down
19 changes: 15 additions & 4 deletions test/verify/check-pages
Original file line number Diff line number Diff line change
Expand Up @@ -639,6 +639,9 @@ OnCalendar=daily
time.sleep(1)
b.input_text("stuff/")
b.wait_in_text("#file-autocomplete-widget li:nth-of-type(5) button", "other")
# close the selector
b.click("#demo-file-ac input")
b.wait_not_present("#file-autocomplete-widget")

# Create test folder with known files
m.execute("mkdir /home/admin/newdir")
Expand All @@ -647,13 +650,21 @@ OnCalendar=daily
m.execute("touch /home/admin/newdir/file1")
m.execute("touch /home/admin/newdir/file2")
# test pre-selected autocomplete widget
b.wait_val("div#demo-file-ac-preselected input", "/home/admin/newdir/file1")
b.click("div#demo-file-ac-preselected input")
b.click("div#demo-file-ac input")
b.click("div#demo-file-ac-preselected input")
b.wait_val("#demo-file-ac-preselected input", "/home/admin/newdir/file1")
# open the selector
b.click("#demo-file-ac-preselected input")
b.wait_visible("#file-autocomplete-widget-preselected")
# close and open again to reload the dir (which just got created)
b.click("#demo-file-ac-preselected input")
b.wait_not_present("#file-autocomplete-widget-preselected")
b.click("#demo-file-ac-preselected input")
# selection has all the files in the directory
paths = ["/home/admin/newdir", "/home/admin/newdir/dir1", "/home/admin/newdir/dir2", "/home/admin/newdir/file1", "/home/admin/newdir/file2"]
for i in range(5):
b.wait_in_text(f"#file-autocomplete-widget-preselected li:nth-of-type({i + 1}) button", paths[i])
# clicking on input again hides selector dropdown again
b.click("#demo-file-ac-preselected input")
b.wait_not_present("#file-autocomplete-widget-preselected")

@testlib.skipOstree("No PCP available")
@testlib.skipImage("pcp not currently in testing", "debian-testing")
Expand Down
2 changes: 2 additions & 0 deletions test/verify/check-superuser
Original file line number Diff line number Diff line change
Expand Up @@ -228,6 +228,8 @@ session include system-auth
b.open_superuser_dialog()
b.wait_in_text(".pf-v5-c-modal-box", "Password for admin")
self.assertIn("cockpit-askpass", m.execute("loginctl --lines=0 user-status admin"))
b.click(".pf-v5-c-modal-box button:contains('Cancel')")
b.wait_not_present(".pf-v5-c-modal-box")
b.logout()
testlib.wait(lambda: "sudo" not in m.execute("loginctl --lines=0 user-status admin || true"), tries=10)
self.assertNotIn("cockpit", m.execute("loginctl --lines=0 user-status admin || true"))
Expand Down
5 changes: 4 additions & 1 deletion test/verify/check-system-journal
Original file line number Diff line number Diff line change
Expand Up @@ -237,7 +237,10 @@ ExecStart=/bin/sh -c 'sleep 5; for s in $(seq 10); do echo SLOW; sleep 0.1; done

b.click(".pf-v5-c-form__group:contains('Since') button")
b.wait_text(".pf-v5-c-popover__title-text", "Start showing entries on or newer than the specified date.")
b.click(".pf-v5-c-popover__close button")
# FIXME: Closing the popover with the X button also closes the whole search form (just not with ph_mouse)
# b.click(".pf-v5-c-popover__close button")
# Thus close the popover the same way as we opened it, with the (i) icon
b.click(".pf-v5-c-form__group:contains('Since') button")
b.wait_not_present(".pf-v5-c-popover")
b.click("#journal-grep button[aria-label='Open advanced search']")
b.wait_not_present("#journal-cmd-copy")
Expand Down
24 changes: 23 additions & 1 deletion test/verify/check-system-services
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,6 @@ import testlib
from machine import testvm


@testlib.nondestructive
class TestServices(testlib.MachineCase):

def setUp(self):
Expand Down Expand Up @@ -56,6 +55,9 @@ class TestServices(testlib.MachineCase):

def toggle_onoff(self):
self.browser.click(".service-top-panel .pf-v5-c-switch__input")
# move away from the button again to remove the tooltip; it covers the kebab
self.browser.mouse(".service-top-panel .pf-v5-c-switch__input", "mouseleave")
self.browser.wait_not_present("[role='tooltip']")

def do_action(self, action):
self.browser.click(".service-top-panel button.pf-v5-c-menu-toggle")
Expand Down Expand Up @@ -365,6 +367,7 @@ WantedBy=default.target
b.go(f'/system/services#/{suffix}')
b.wait_not_present('#test.service > svg.service-thumbtack-icon-color')

@testlib.nondestructive
def testFilter(self):
b = self.browser

Expand Down Expand Up @@ -495,9 +498,11 @@ WantedBy=default.target
b.click(".pf-v5-c-chip-group:contains('Active state') .pf-v5-c-chip-group__close > button")
b.wait_not_present(".pf-v5-c-chip-group")

@testlib.nondestructive
def testTimer(self):
self._testTimer(user=False)

@testlib.nondestructive
def testTimerSession(self):
self._testTimer(user=True)

Expand Down Expand Up @@ -630,9 +635,11 @@ WantedBy=default.target
b.wait_not_in_text(".service-top-panel .pf-v5-c-menu__list", "Delete")
b.click(".pf-v5-c-breadcrumb a:contains('Services')")

@testlib.nondestructive
def testServiceMetrics(self):
self._testServiceMetrics(user=False)

@testlib.nondestructive
def testServiceMetricsSession(self):
self._testServiceMetrics(user=True)

Expand Down Expand Up @@ -680,9 +687,11 @@ ExecStart=/bin/awk -f /tmp/mem-hog{params}.awk
# Check that listen is not shown for .service units
b.wait_not_present("#listen")

@testlib.nondestructive
def testOtherTypes(self):
self._testOtherTypes(user=False)

@testlib.nondestructive
def testOtherTypesSession(self):
self._testOtherTypes(user=True)

Expand Down Expand Up @@ -785,6 +794,7 @@ ExecStart=/bin/awk -f /tmp/mem-hog{params}.awk
b.enter_page("/system/services")
b.wait_js_cond('window.location.hash === "' + append_user("#/test.service") + '"')

@testlib.nondestructive
def testApi(self):
b = self.browser

Expand All @@ -809,6 +819,7 @@ ExecStart=/bin/awk -f /tmp/mem-hog{params}.awk
b.go('#/foo')
b.wait_text('#exists', 'false')

@testlib.nondestructive
def testConditions(self):
m = self.machine
b = self.browser
Expand Down Expand Up @@ -857,9 +868,11 @@ WantedBy=multi-user.target
self.check_service_on(expect_reload=False)
b.wait_not_present("#condition")

@testlib.nondestructive
def testRelationships(self):
self._testRelationships()

@testlib.nondestructive
def testRelationshipsUser(self):
self._testRelationships(user=True)

Expand Down Expand Up @@ -947,6 +960,7 @@ ExecStart=/bin/sh -c "while true; do sleep 5; done"
b.click("#service-details-show-relationships button")
b.wait_visible(rel_sel("After", "test-a.service"))

@testlib.nondestructive
def testNotFound(self):
m = self.machine
b = self.browser
Expand Down Expand Up @@ -975,6 +989,7 @@ ExecStart=/bin/true
b.click("#service-details-show-relationships button")
b.wait_visible("#Requires a.pf-m-disabled:contains('not-found.service')")

@testlib.nondestructive
def testResetFailed(self):
m = self.machine
b = self.browser
Expand Down Expand Up @@ -1055,6 +1070,7 @@ WantedBy=default.target
b.click("#nav-system-item")
b.set_layout("desktop")

@testlib.nondestructive
def testNotifyFailed(self):
m = self.machine
b = self.browser
Expand Down Expand Up @@ -1126,6 +1142,7 @@ ExecStart=/usr/bin/false
b.wait_not_in_text(".system-health-events", "service has failed")
b.wait_not_in_text(".system-health-events", "services have failed")

@testlib.nondestructive
def testHiddenFailure(self):
m = self.machine
b = self.browser
Expand Down Expand Up @@ -1159,6 +1176,7 @@ Where=/fail

self.allow_journal_messages(".*type=1400 audit(.*): avc: denied { create } .* comm=\"systemd\" name=\"fail\".*")

@testlib.nondestructive
def testTransientUnits(self):
m = self.machine
b = self.browser
Expand Down Expand Up @@ -1194,6 +1212,7 @@ Where=/fail
b.switch_to_top()
b.wait_js_cond('window.location.pathname == "/system/services"')

@testlib.nondestructive
def testServicesThemeConsistency(self):
b = self.browser

Expand All @@ -1208,6 +1227,7 @@ Where=/fail
b.enter_page("/system/services")
b.wait_not_present("html.pf-v5-theme-dark")

@testlib.nondestructive
def testServicesFiltersURLConsistency(self):
b = self.browser

Expand Down Expand Up @@ -1267,6 +1287,7 @@ Where=/fail
self.assertNotIn("Not running", b.text(".services-list"))
self.assertNotIn("Static", b.text(".services-list"))

@testlib.nondestructive
def testAlias(self):
m = self.machine
b = self.browser
Expand Down Expand Up @@ -1323,6 +1344,7 @@ WantedBy=multi-user.target
b.wait_in_text("#service-details-unit", "/etc/systemd/system/flower-rose.service")
b.wait_in_text('.cockpit-log-panel .pf-v5-c-card__body', "Perfume")

@testlib.nondestructive
def testUnprivileged(self):
b = self.browser
m = self.machine
Expand Down
1 change: 1 addition & 0 deletions test/verify/check-users
Original file line number Diff line number Diff line change
Expand Up @@ -279,6 +279,7 @@ class TestAccounts(testlib.MachineCase):
# disable password
performUserAction(b, 'robert', 'Lock account')
b.click("#account-confirm-lock-dialog footer .pf-m-danger.apply")
b.wait_not_present("#account-confirm-lock-dialog")
# lock option is now disabled
b.click("#accounts-list tbody tr:contains(robert) button.pf-v5-c-menu-toggle")
b.wait_in_text(".pf-v5-c-menu__list-item:contains('Lock account').pf-m-disabled", 'Lock account')
Expand Down
2 changes: 1 addition & 1 deletion tools/debian/copyright.template
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ Files: src/appstream/*.metainfo.xml.in src/client/*.metainfo.xml
Copyright: Copyright (C) 2018 Red Hat, Inc.
License: CC0-1.0
On Debian systems, the complete text of the Creative Commons Zero v1.0
Universal Public License is in "/usr/share/common-licenses/LGPL-2.1".
Universal Public License is in "/usr/share/common-licenses/CC0-1.0".

#NPM# semi-autogenerated records for node_modules/ go here

Expand Down