Skip to content

Commit

Permalink
Merge pull request #119 from manics/raw_socket_proxy
Browse files Browse the repository at this point in the history
Remove websockify, add Playwright test
  • Loading branch information
consideRatio authored Jul 8, 2024
2 parents 3f8e3a2 + 70393e5 commit 7f16655
Show file tree
Hide file tree
Showing 11 changed files with 140 additions and 105 deletions.
96 changes: 27 additions & 69 deletions .github/workflows/test.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -36,29 +36,29 @@ jobs:
steps:
- uses: actions/checkout@v4

- uses: actions/setup-python@v5
with:
python-version: "3.11"

- name: Cache playwright binaries
uses: actions/cache@v4
with:
path: |
~/.cache/ms-playwright
key: ${{ runner.os }}-playwright

- name: Build image
run: |
docker build --progress=plain --build-arg vncserver=${{ matrix.vncserver }} -t test .
- name: (inside container) websockify --help
run: |
docker run test websockify --help
- name: (inside container) vncserver -help
run: |
# -help flag is not available for TurboVNC, but it emits the -help
# equivalent information anyhow if passed -help, but also errors. Due
# to this, we fallback to use the errorcode of vncsrever -list.
docker run test bash -c "vncserver -help || vncserver -list > /dev/null"
- name: Install websocat, a test dependency"
run: |
wget -q https://github.com/vi/websocat/releases/download/v1.12.0/websocat.x86_64-unknown-linux-musl \
-O /usr/local/bin/websocat
chmod +x /usr/local/bin/websocat
- name: Test vncserver
if: always()
run: |
container_id=$(docker run -d -it -p 5901:5901 test vncserver -xstartup /opt/install/jupyter_remote_desktop_proxy/share/xstartup -verbose -fg -geometry 1680x1050 -SecurityTypes None -rfbport 5901)
sleep 1
Expand All @@ -79,71 +79,24 @@ jobs:
docker stop $container_id > /dev/null
if [ "$TEST_OK" == "false" ]; then
echo "One or more tests failed!"
echo "Test failed!"
exit 1
fi
- name: Test websockify'ed vncserver
if: always()
- name: Install playwright
run: |
container_id=$(docker run -d -it -p 5901:5901 test websockify --verbose --log-file=/tmp/websockify.log --heartbeat=30 5901 -- vncserver -xstartup /opt/install/jupyter_remote_desktop_proxy/share/xstartup -verbose -fg -geometry 1680x1050 -SecurityTypes None -rfbport 5901)
sleep 1
echo "::group::Install websocat, a test dependency"
docker exec --user root $container_id bash -c '
wget -q https://github.com/vi/websocat/releases/download/v1.12.0/websocat.x86_64-unknown-linux-musl \
-O /usr/local/bin/websocat
chmod +x /usr/local/bin/websocat
'
echo "::endgroup::"
docker exec -it $container_id websocat --binary --one-message --exit-on-eof "ws://localhost:5901/" 2>&1 | tee -a /dev/stderr | \
grep --quiet RFB && echo "Passed test" || { echo "Failed test" && TEST_OK=false; }
echo "::group::websockify logs"
docker exec $container_id bash -c "cat /tmp/websockify.log"
echo "::endgroup::"
echo "::group::vncserver logs"
docker exec $container_id bash -c 'cat ~/.vnc/*.log'
echo "::endgroup::"
python -mpip install -r dev-requirements.txt
python -mplaywright install --with-deps
docker stop $container_id > /dev/null
if [ "$TEST_OK" == "false" ]; then
echo "One or more tests failed!"
exit 1
fi
- name: Test project's proxy to websockify'ed vncserver
if: always()
- name: Playwright browser test
run: |
container_id=$(docker run -d -it -p 8888:8888 -e JUPYTER_TOKEN=secret test)
sleep 3
export CONTAINER_ID=$container_id
export JUPYTER_HOST=http://localhost:8888
export JUPYTER_TOKEN=secret
curl --silent --fail 'http://localhost:8888/desktop/?token=secret' | grep --quiet 'Jupyter Remote Desktop Proxy' && echo "Passed get index.html test" || { echo "Failed get index.html test" && TEST_OK=false; }
curl --silent --fail 'http://localhost:8888/desktop/static/dist/viewer.js?token=secret' > /dev/null && echo "Passed get viewer.js test" || { echo "Failed get viewer.js test" && TEST_OK=false; }
# The first attempt often fails, but the second always(?) succeeds.
#
# This could be related to jupyter-server-proxy's issue
# https://github.com/jupyterhub/jupyter-server-proxy/issues/459
# because the client/proxy websocket handshake completes before the
# proxy/server handshake. This issue is tracked for this project by
# https://github.com/jupyterhub/jupyter-remote-desktop-proxy/issues/105.
#
websocat --binary --one-message --exit-on-eof 'ws://localhost:8888/desktop-websockify/?token=secret' 2>&1 \
| tee -a /dev/stderr \
| grep --quiet RFB \
&& echo "Passed initial websocket test" \
|| { \
echo "Failed initial websocket test" \
&& sleep 1 \
&& websocat --binary --one-message --exit-on-eof 'ws://localhost:8888/desktop-websockify/?token=secret' 2>&1 \
| tee -a /dev/stderr \
| grep --quiet RFB \
&& echo "Passed second websocket test" \
|| { echo "Failed second websocket test" && TEST_OK=false; } \
}
python -mpytest -vs
echo "::group::jupyter_server logs"
docker logs $container_id
Expand All @@ -160,5 +113,10 @@ jobs:
exit 1
fi
# TODO: Check VNC desktop works, e.g. by comparing Playwright screenshots
# https://playwright.dev/docs/test-snapshots
- name: Upload screenshot
uses: actions/upload-artifact@v4
if: always()
with:
name: screenshots-${{ matrix.vncserver }}
path: screenshots/*
if-no-files-found: error
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -134,3 +134,6 @@ dmypy.json

# Pyre type checker
.pyre/

# Additional ignores
screenshots/
5 changes: 3 additions & 2 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ USER root
RUN apt-get -y -qq update \
&& apt-get -y -qq install \
dbus-x11 \
# xclip is added as jupyter-remote-desktop-proxy's tests requires it
xclip \
xfce4 \
xfce4-panel \
xfce4-session \
Expand Down Expand Up @@ -55,5 +57,4 @@ RUN . /opt/conda/bin/activate && \

COPY --chown=$NB_UID:$NB_GID . /opt/install
RUN . /opt/conda/bin/activate && \
pip install -e /opt/install && \
jupyter server extension enable jupyter_remote_desktop_proxy
pip install /opt/install
7 changes: 1 addition & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,12 +31,7 @@ For an example, see the [`Dockerfile`](./Dockerfile) in this repository which in
pip install jupyter-remote-desktop-proxy
```

2. Install the [websockify](https://github.com/novnc/websockify) dependency. Unfortunately,
the PyPI `websockify` package is broken, so you need to install it either
from [conda-forge](https://anaconda.org/conda-forge/websockify) or with
[apt](https://packages.ubuntu.com/search?suite=all&searchon=names&keywords=websockify)

3. Install the packages needed to provide a VNC server and the actual Linux Desktop environment.
2. Install the packages needed to provide a VNC server and the actual Linux Desktop environment.
You need to pick a desktop environment (there are many!) - here are the packages
to use TigerVNC and the light-weight [XFCE4](https://www.xfce.org/) desktop environment on Ubuntu 22.04:

Expand Down
3 changes: 3 additions & 0 deletions dev-requirements.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
pillow==10.3.0
playwright==1.44.0
pytest==8.2.2
8 changes: 1 addition & 7 deletions environment.yml
Original file line number Diff line number Diff line change
@@ -1,12 +1,6 @@
# Unfortunately the version of websockify on PyPI doesn't include the [compiled
# wrapper library](https://github.com/novnc/websockify#wrap-a-program) which is
# used by this extension, so either you'd have to manually compile it after pip
# installing websockify, or use the conda package.
#
channels:
- conda-forge
dependencies:
- jupyter-server-proxy>=1.4
- jupyter-server-proxy>=4.3.0
- jupyterhub-singleuser
- pip
- websockify
26 changes: 6 additions & 20 deletions jupyter_remote_desktop_proxy/setup_websockify.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import os
import shlex
import tempfile
from shutil import which

HERE = os.path.dirname(os.path.abspath(__file__))
Expand All @@ -12,8 +11,6 @@ def setup_websockify():
raise RuntimeError(
"vncserver executable not found, please install a VNC server"
)
if not which('websockify'):
raise RuntimeError("websockify executable not found, please install websockify")

# TurboVNC and TigerVNC share the same origin and both use a Perl script
# as the executable vncserver. We can determine if vncserver is TigerVNC
Expand All @@ -30,16 +27,10 @@ def setup_websockify():
is_tigervnc = "tigervnc" in vncserver_file.read().casefold()

if is_tigervnc:
# Make a secure temporary directory for sockets that is only readable,
# writeable, and searchable by our uid - TigerVNC can listen to a Unix
# socket!
sockets_dir = tempfile.mkdtemp()
sockets_path = os.path.join(sockets_dir, 'vnc-socket')

websockify_args = ['--unix-target', sockets_path]
vnc_args = [vncserver, '-rfbunixpath', sockets_path]
unix_socket = True
vnc_args = [vncserver, '-rfbunixpath', "{unix_socket}"]
else:
websockify_args = []
unix_socket = False
vnc_args = [vncserver, '-localhost', '-rfbport', '{port}']

if not os.path.exists(os.path.expanduser('~/.vnc/xstartup')):
Expand All @@ -58,18 +49,13 @@ def setup_websockify():
)

return {
'command': [
'websockify',
'--verbose',
'--heartbeat=30',
'{port}',
]
+ websockify_args
+ ['--', '/bin/sh', '-c', f'cd {os.getcwd()} && {vnc_command}'],
'command': ['/bin/sh', '-c', f'cd {os.getcwd()} && {vnc_command}'],
'timeout': 30,
'new_browser_window': True,
# We want the launcher entry to point to /desktop/, not to /desktop-websockify/
# /desktop/ is the user facing URL, while /desktop-websockify/ now *only* serves
# websockets.
"launcher_entry": {"title": "Desktop", "path_info": "desktop"},
"unix_socket": unix_socket,
"raw_socket_proxy": True,
}
2 changes: 1 addition & 1 deletion setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ def run(self):
]
},
install_requires=[
'jupyter-server-proxy>=4.1.1',
'jupyter-server-proxy>=4.3.0',
],
include_package_data=True,
keywords=["Interactive", "Desktop", "Jupyter"],
Expand Down
18 changes: 18 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
from os import getenv

import pytest
from playwright.sync_api import sync_playwright

HEADLESS = getenv("HEADLESS", "1") == "1"


@pytest.fixture()
def browser():
# browser_type in ["chromium", "firefox", "webkit"]
with sync_playwright() as playwright:
browser = playwright.firefox.launch(headless=HEADLESS)
context = browser.new_context()
page = context.new_page()
yield page
context.clear_cookies()
browser.close()
Binary file added tests/reference/desktop.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
77 changes: 77 additions & 0 deletions tests/test_browser.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
from os import getenv
from pathlib import Path
from shutil import which
from subprocess import check_output
from uuid import uuid4

from PIL import Image, ImageChops
from playwright.sync_api import expect

HERE = Path(__file__).absolute().parent

CONTAINER_ID = getenv("CONTAINER_ID", "test")
JUPYTER_HOST = getenv("JUPYTER_HOST", "http://localhost:8888")
JUPYTER_TOKEN = getenv("JUPYTER_TOKEN", "secret")


def compare_screenshot(test_image, threshold=2):
# Compare images by calculating the mean absolute difference
# Images must be the same size
# threshold: Average difference per pixel, this depends on the image type
# e.g. for 24 bit images (8 bit RGB pixels) threshold=1 means a maximum
# difference of 1 bit per pixel per channel
reference = Image.open(HERE / "reference" / "desktop.png")
test = Image.open(test_image)

# Absolute difference
# Convert to RGB, alpha channel breaks ImageChops
diff = ImageChops.difference(reference.convert("RGB"), test.convert("RGB"))
diff_data = diff.getdata()

m = sum(sum(px) for px in diff_data) / diff_data.size[0] / diff_data.size[1]
assert m < threshold


# To debug this set environment variable HEADLESS=0
def test_desktop(browser):
page = browser
page.goto(f"{JUPYTER_HOST}/lab?token={JUPYTER_TOKEN}")
page.wait_for_url(f"{JUPYTER_HOST}/lab")

# JupyterLab extension icon
expect(page.get_by_text("Desktop [↗]")).to_be_visible()
with page.expect_popup() as page1_info:
page.get_by_text("Desktop [↗]").click()
page1 = page1_info.value
page1.wait_for_url(f"{JUPYTER_HOST}/desktop/")

expect(page1.get_by_text("Status: Connected")).to_be_visible()
expect(page1.locator("canvas")).to_be_visible()

# Screenshot the desktop element only
# May take a few seconds to load
page1.wait_for_timeout(5000)
# Use a non temporary folder so we can check it manually if necessary
screenshot = Path("screenshots") / "desktop.png"
page1.locator("canvas").screenshot(path=screenshot)

# Open clipboard, enter random text, close clipboard
clipboard_text = str(uuid4())
page1.get_by_role("link", name="Remote Clipboard").click()
page1.wait_for_selector("#clipboard-text")
page1.locator("#clipboard-text").click()
page1.locator("#clipboard-text").fill(clipboard_text)
page1.get_by_role("link", name="Remote Clipboard").click()

# Exec into container to check clipboard contents
for engine in ["docker", "podman"]:
if which(engine):
break
else:
raise RuntimeError("Container engine not found")
clipboard = check_output(
[engine, "exec", "-eDISPLAY=:1", CONTAINER_ID, "xclip", "-o"]
)
assert clipboard.decode() == clipboard_text

compare_screenshot(screenshot)

0 comments on commit 7f16655

Please sign in to comment.