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

CDP replacement candidate: direct python API for BiDi #934

Closed
wants to merge 28 commits into from

Conversation

martinpitt
Copy link
Member

@martinpitt martinpitt commented Jul 24, 2024

This is another candidate for https://issues.redhat.com/browse/COCKPIT-1139 , in the similar spirit as #926

This talks to https://w3c.github.io/webdriver-bidi/ directly, requires zero new npm dependencies (in fact, we can drop chrome-remote-interface), and only a new package (chromedriver) in the tasks container (installed locally for this demo added to official container now).

This is much further ahead than #926, in the sense of that it actually is a Python test, in our usual testlib shape. We won't need any extra node process to talk to a JS webdriver module any more, and they aren't any good ones for BiDi anyway.

Compared to CDP this has the following advantages:

  • Fewer moving parts and less code
  • At the API level, Firefox and Chromium behave the same, so we can drop our {firefox,chromium}-cdp-driver.js forks
  • Very low input API level, we can even simulate touch swipes and others. With CDP we just synthesize a MouseEvent, while this actually goes through the browser.

Hiccups:

https://issues.redhat.com/browse/COCKPIT-1154

@martinpitt
Copy link
Member Author

@jelly @allisonkarlitskaya At this point it seems pretty clear to me that this approach is much better than #926 (where all of the required JS → Python glue isn't even written, it uses the outdated webdriver protocol, and requires umpteen new dependencies, and it's also slower).

This is demo code, so no need for a minutely review, but I'd like to hear your coarse-grained opinion about the approach. Thanks!

Copy link
Member

@allisonkarlitskaya allisonkarlitskaya left a comment

Choose a reason for hiding this comment

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

This is a very cool piece of work.

I would prefer to see more separation between driver/session/context. It might be useful to think about sharing these things (but my experiments also show problems there, particularly with respect to each fresh pytest getting its own new asyncio event loop). So maybe the added complication is not helpful there.

I think the next thing I'd like to see is a version of this integrated into test/common/ in the cockpit repo....

asyncio.run_coroutine_threadsafe(self.driver.start_session(), self.loop).result()

def close(self):
asyncio.run_coroutine_threadsafe(self.driver.close(), self.loop).result()
Copy link
Member

Choose a reason for hiding this comment

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

This run_coroutine_threadsafe() approach is very cool. Much better than the custom-built machinery I did in my version of this, and probably more efficient, too.

Copy link
Member Author

Choose a reason for hiding this comment

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

Thanks! I'm surprised that it surprised you 😉

driver: bidi.WebdriverBidi

@staticmethod
def asyncio_loop_thread(loop: asyncio.AbstractEventLoop) -> None:
Copy link
Member

Choose a reason for hiding this comment

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

I generally like the approach of binding the bidi driver and the asyncio loop thread directly to the Browser object. I think that's probably right. Once we start interacting with the websocket in the asyncio loop running in that thread, we really ought not to touch it from any other context.

We can dream about sharing the session some day, but for now, this seems right.

Copy link
Member Author

Choose a reason for hiding this comment

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

Wrt. sharing the session: I share the sentiment. Starting up chromium is lighting fast, but starting firefox takes ~ 5 seconds annoyingly. It would be cool to leave the process running and just instantiate sessions. I didn't test how isolated they are from one another though -- standard "tab" isolation isn't enough (we need separate cookie/localStorage for each tab), but if it's more like "private window" that'd be great.

But that isn't a regression -- our current CDP library also starts firefox with each Browser instance, so for now I'd like to keep that in the backlog. This project is huge enough even without doing additional structural changes at the same time 😅

However, for your pytest work the picture is different -- you don't nearly need so much machinery there (no frame tracking, cookies, mouse clicks, all the testlib API, no firefox, no coverage, etc.). While we can certainly look at uniting the bidi driver stuff at some point, it may be easier to just keep it separate for the first version. WDYT?

args: list[object]
text: str

def __init__(self, message_params):
Copy link
Member

Choose a reason for hiding this comment

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

How do you feel about the cockpit json helpers? I notice you have your own JsonObject defined above, and indeed, we can't import jsonutil from starter-kit. I wonder if we should move those helpers to a more neutral location where they can also get used by tests.

It's a bit of a weird situation. We might end up with two copies... or maybe we don't have to care about typing too much 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.

Heh yes, had the same "wanna but can't" feeling. But TBH we just need it at exactly one place, I feel it's probably not worth sharing bridge with testlib code for that. Maybe if we run into more of these, but I don't see that coming -- the protocol side is basically "done", the remaining work is all on the "outwards" testlib side.

if data["type"] == "event":
if data["method"] == "log.entryAdded":
log = LogMessage(data["params"])
self.logs.append(log)
Copy link
Member

Choose a reason for hiding this comment

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

I think this isn't useful enough for qunit tests. This needs to be at least an async queue or something else that can be awaited.

Copy link
Member Author

Choose a reason for hiding this comment

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

Ah, sure -- cf. my question above with "does it already make sense at this point to use that exact API for the unit tests"? Do you have a pointer for what a unit test is trying to do that relies on waiting for log messages? There could certainly be one method for "set up a log watch future", and resolve it here if it's set up; is that enough?

Copy link
Member Author

Choose a reason for hiding this comment

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

See ./test/common/tap-cdp and cdp.read_log()

Copy link
Member Author

Choose a reason for hiding this comment

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

bidi.py Show resolved Hide resolved
bidi.py Show resolved Hide resolved
@martinpitt
Copy link
Member Author

I think the next thing I'd like to see is a version of this integrated into test/common/ in the cockpit repo....

Right, that's the plan. This is strictly "draft", mostly because it's a convenient place for a demo, and we wanted to compare different approaches.

@martinpitt martinpitt force-pushed the direct-bidi branch 2 times, most recently from 8d9eca3 to 0f31b7f Compare July 24, 2024 14:18
@martinpitt martinpitt force-pushed the direct-bidi branch 2 times, most recently from 218f7fa to ff3cb22 Compare July 26, 2024 14:24
This makes waiting for text robust.

script.addPreloadScript() doesn't export the declared functions, so we
need to attach them to `window`. We also don't need all of them.
This is all sync code, with sketch of what the updated Browser class
looks like.
This better lives in the sync Browser class.
This works fine with Firefox, and conforms to the spec.

However, Chromium gets confused and clicks on the wrong position. Work
around that for now by keeping our old `ph_mouse()` event synthesizer
for Chromium.
Use the same `document` tracking fix/hack as in our CDP driver.
@martinpitt
Copy link
Member Author

Wow, it's very straightforward to talk to CDP. chromedriver already enables CDP and gives you the address in the returned capabilities. And looks like not only the bidi sessions/tabs appear there, they even have the exact same session IDs as bidi/webdriver:

❱❱❱ curl http://localhost:37773/json/list
[ {
   "description": "",
   "devtoolsFrontendUrl": "/devtools/inspector.html?ws=localhost:37773/devtools/page/9E5C285BA99CC696EE4FD2390D303029",
   "id": "9E5C285BA99CC696EE4FD2390D303029",
   "title": "fedora-40-127-0-0-2-2201",
   "type": "page",
   "url": "http://127.0.0.2:9091/",
   "webSocketDebuggerUrl": "ws://localhost:37773/devtools/page/9E5C285BA99CC696EE4FD2390D303029"
}, {
   "description": "",
   "devtoolsFrontendUrl": "/devtools/inspector.html?ws=localhost:37773/devtools/page/401D4B511DB168958EFF68E8F2F32087",
   "id": "401D4B511DB168958EFF68E8F2F32087",
   "title": "BiDi-CDP Mapper",
   "type": "page",
   "url": "data:,",
   "webSocketDebuggerUrl": "ws://localhost:37773/devtools/page/401D4B511DB168958EFF68E8F2F32087"
} ]

Unfortunately, websocat ws://localhost:37773/devtools/page/9E5C285BA99CC696EE4FD2390D303029 immediately fails with "websocat: WebSocketError: I/O failure" in both default text and binary (-b) mode. However, it works fine with aiohttp, and I can send commands and receive replies. I just wonder about this:

DEBUG:bidi.proto:CDP ← '{"id": 0, "method": "Profiler.enable", "params": {}}'
DEBUG:bidi.proto:CDP → {'id': 0, 'result': {}}
DEBUG:bidi.proto:CDP ← '{"id": 1, "method": "Profiler.startPreciseCoverage", "params": {"callCount": false, "detailed": true}}'
DEBUG:bidi.proto:CDP → {'id': 1, 'error': {'code': -32000, 'message': 'Profiler is not enabled'}}

That's pretty much exactly what CDP.start() does in our current code.. 🤔

I am currently trying to only connect to the CDP websocket when necessary, it seems a big waste to let it run all the time -- then we have to read all the messages and discard them sigh. But that's my next attempt,

This is not accessible via BiDi or webdriver, but fortunately the CDP
and BiDi sessions are compatible.
@martinpitt
Copy link
Member Author

Look ma, CDP Profiler (aka coverage) support! That was my final TODO item that I could think of. Now I think it's time to aim higher and put it into actual cockpit.

@martinpitt
Copy link
Member Author

This was just a demo and has fulfilled its purpose. cockpit-project/cockpit#20832 is Ze Real Zing.

@martinpitt martinpitt closed this Aug 6, 2024
@martinpitt martinpitt deleted the direct-bidi branch August 6, 2024 06:42
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

2 participants