From 7aee66065a507228ffde29f72d0fb78e39974b21 Mon Sep 17 00:00:00 2001 From: YuviPanda Date: Wed, 14 Feb 2024 20:52:43 -0800 Subject: [PATCH 01/23] Add an integration test for VNC + websockify This tests the following components: 1. The VNC server 2. websockify 3. Jupyter Server Proxy 4. The desktop environment itself It does so by: 1. Building the container image 2. Starting a container with that image, with jupyter server running inside. 3. Connecting to the container via websockets, authenticating and exposing the websocket connection as a regular TCP socket on localhost via [websocat](https://github.com/vi/websocat) 4. Taking a screenshot of the remote desktop by connecting to it through `vncdotool`. This requires a tcp connection, hence the double proxying with websocat 5. Verify that the image produced by the screenshot is the same as a reference image we have. This only misses the noVNC + JS from testing, but otherwise represents a massive improvement to the status quo! --- .github/workflows/test.yaml | 130 +++----------------------------- dev-requirements.txt | 1 + integration-tests/expected.jpeg | Bin 0 -> 47675 bytes integration-tests/test_vnc.py | 110 +++++++++++++++++++++++++++ 4 files changed, 120 insertions(+), 121 deletions(-) create mode 100644 dev-requirements.txt create mode 100644 integration-tests/expected.jpeg create mode 100644 integration-tests/test_vnc.py diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index dc2b8c4d..0cce1b9c 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -35,130 +35,18 @@ jobs: steps: - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.10" - - name: Build image + - name: Install testing requirements run: | - docker build --progress=plain --build-arg vncserver=${{ matrix.vncserver }} -t test . + pip install -r dev-requirements.txt - - 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 + wget 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() + - name: Run Integration tests 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 - - echo "::group::Install netcat, a test dependency" - docker exec --user root $container_id bash -c ' - apt update - apt install -y netcat - ' - echo "::endgroup::" - - docker exec -it $container_id timeout --preserve-status 1 nc -v localhost 5901 2>&1 | tee -a /dev/stderr | \ - grep --quiet RFB && echo "Passed test" || { echo "Failed test" && TEST_OK=false; } - - echo "::group::vncserver logs" - docker exec $container_id bash -c 'cat ~/.vnc/*.log' - echo "::endgroup::" - - docker stop $container_id > /dev/null - if [ "$TEST_OK" == "false" ]; then - echo "One or more tests failed!" - exit 1 - fi - - - name: Test websockify'ed vncserver - if: always() - 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::" - - 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() - run: | - container_id=$(docker run -d -it -p 8888:8888 -e JUPYTER_TOKEN=secret test) - sleep 3 - - 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; } \ - } - - echo "::group::jupyter_server logs" - docker logs $container_id - echo "::endgroup::" - - echo "::group::vncserver logs" - docker exec $container_id bash -c 'cat ~/.vnc/*.log' - echo "::endgroup::" - - timeout 5 docker stop $container_id > /dev/null && echo "Passed SIGTERM test" || { echo "Failed SIGTERM test" && TEST_OK=false; } - - if [ "$TEST_OK" == "false" ]; then - echo "One or more tests failed!" - exit 1 - fi - - # TODO: Check VNC desktop works, e.g. by comparing Playwright screenshots - # https://playwright.dev/docs/test-snapshots + py.test integration-tests/ \ No newline at end of file diff --git a/dev-requirements.txt b/dev-requirements.txt new file mode 100644 index 00000000..e079f8a6 --- /dev/null +++ b/dev-requirements.txt @@ -0,0 +1 @@ +pytest diff --git a/integration-tests/expected.jpeg b/integration-tests/expected.jpeg new file mode 100644 index 0000000000000000000000000000000000000000..c520ace6f6b3f35b519f63387ca78b0f3709b0be GIT binary patch literal 47675 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≫#e>(NPLBb99E!+>wu}E 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 E7i2L+tpET3 literal 0 HcmV?d00001 diff --git a/integration-tests/test_vnc.py b/integration-tests/test_vnc.py new file mode 100644 index 00000000..98584941 --- /dev/null +++ b/integration-tests/test_vnc.py @@ -0,0 +1,110 @@ +""" +Test the VNC server is serving images +""" + +import secrets +import subprocess +import time +from pathlib import Path + +import pytest +from vncdotool import api + +REPO_ROOT = Path(__file__).parent.parent + + +# Rebuild once every test session if needed, relying on docker cache +# to keep that fast +@pytest.fixture(scope="session") +def container_image() -> str: + """ + Provide a built container image name + """ + # Use a different tag name here each time, relying on docker cache to + # keep things fast and never having to deal with stale image problems + image_name = f"jupyter-remote-desktop-proxy-integration-test:{secrets.token_hex(8)}" + cmd = ["docker", "build", "-t", image_name, str(REPO_ROOT)] + subprocess.check_call(cmd) + return image_name + + +@pytest.fixture +def container(container_image) -> tuple[str, str]: + """ + Provide a running container with jupyter server running + + Returns a tuple of (port, token), where port is the *local* port + that is forwarded to the jupyter server port inside the docker container, + and token is the authentication token to be used when talking to the + remote container. + """ + token = secrets.token_hex(16) + cmd = [ + "docker", + "run", + "-p", + "8888:8888", + "--rm", + "-d", + "--security-opt", + "seccomp=unconfined", + "--security-opt", + "apparmor=unconfined", + container_image, + "jupyter", + "server", + f"--IdentityProvider.token={token}", + ] + container_name = subprocess.check_output(cmd).decode() + # FIXME: Instead, wait for container to be ready here + time.sleep(5) + + try: + # FIXME: Dynamically allocate this port + yield (8888, token) + finally: + subprocess.check_call(['docker', 'stop', container_name]) + + +def test_vnc_screenshot(container, image_diff): + port, token = container + websocat_proc = subprocess.Popen( + [ + 'websocat', + '--binary', + '--exit-on-eof', + # FIXME: Dynamically allocate this port too + 'tcp-l:127.0.0.1:5999', + f'ws://127.0.0.1:{port}/desktop-websockify/?token={token}', + ] + ) + try: + with api.connect('127.0.0.1::5999') as client: + time.sleep(5) + client.captureScreen("test.jpeg") + assert image_diff( + str(REPO_ROOT / "integration-tests/expected.jpeg"), "test.jpeg" + ) + # subprocess.check_call([ + # 'vncdo', + # '-vv', + # '-s', + # '127.0.0.1::5999', + # 'expect', + # '5' + # ]) + # time.sleep(10) + # subprocess.check_call([ + # 'vncdo', + # '-s', + # '127.0.0.1::5999', + # 'capture', + # 'capture.jpeg' + # ]) + finally: + # Explicitly shutdown vncdo, as otherwise a stray thread keeps + # running forever + api.shutdown() + + websocat_proc.kill() + websocat_proc.wait() From 44137e0d41ed8dbd3193d7937b9a7bd0b6fc41fa Mon Sep 17 00:00:00 2001 From: YuviPanda Date: Wed, 14 Feb 2024 20:53:37 -0800 Subject: [PATCH 02/23] Add missing packages to dev-requirements.txt --- dev-requirements.txt | 2 ++ 1 file changed, 2 insertions(+) diff --git a/dev-requirements.txt b/dev-requirements.txt index e079f8a6..3b730ec3 100644 --- a/dev-requirements.txt +++ b/dev-requirements.txt @@ -1 +1,3 @@ pytest +pytest-image-diff +vncdotool From 557135e12ced2a6965657055d9e1abf53f77b2b0 Mon Sep 17 00:00:00 2001 From: YuviPanda Date: Wed, 14 Feb 2024 20:54:58 -0800 Subject: [PATCH 03/23] Show output as test is being run --- .github/workflows/test.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index 0cce1b9c..f4cc0419 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -49,4 +49,4 @@ jobs: - name: Run Integration tests run: | - py.test integration-tests/ \ No newline at end of file + py.test integration-tests/ -s \ No newline at end of file From fbf2fd14d47144c38b6204dd52f64961939f8e72 Mon Sep 17 00:00:00 2001 From: YuviPanda Date: Wed, 14 Feb 2024 21:34:44 -0800 Subject: [PATCH 04/23] Dynamically allocate forwarded port Prevents clashes between multiple runs Also remove some old commented out code --- integration-tests/test_vnc.py | 49 ++++++++++++++++------------------- 1 file changed, 23 insertions(+), 26 deletions(-) diff --git a/integration-tests/test_vnc.py b/integration-tests/test_vnc.py index 98584941..718c6af6 100644 --- a/integration-tests/test_vnc.py +++ b/integration-tests/test_vnc.py @@ -2,6 +2,7 @@ Test the VNC server is serving images """ +import json import secrets import subprocess import time @@ -42,8 +43,7 @@ def container(container_image) -> tuple[str, str]: cmd = [ "docker", "run", - "-p", - "8888:8888", + "-P", "--rm", "-d", "--security-opt", @@ -55,19 +55,28 @@ def container(container_image) -> tuple[str, str]: "server", f"--IdentityProvider.token={token}", ] - container_name = subprocess.check_output(cmd).decode() + container_name = subprocess.check_output(cmd).decode().strip() + # FIXME: Instead, wait for container to be ready here time.sleep(5) + container_info = json.loads( + subprocess.check_output( + ['docker', 'container', 'inspect', container_name] + ).decode() + ) + + exposed_port = container_info[0]["NetworkSettings"]["Ports"]["8888/tcp"][0] + origin = f"{exposed_port['HostIp']}:{exposed_port['HostPort']}" + try: - # FIXME: Dynamically allocate this port - yield (8888, token) + yield (origin, token) finally: subprocess.check_call(['docker', 'stop', container_name]) def test_vnc_screenshot(container, image_diff): - port, token = container + origin, token = container websocat_proc = subprocess.Popen( [ 'websocat', @@ -75,32 +84,20 @@ def test_vnc_screenshot(container, image_diff): '--exit-on-eof', # FIXME: Dynamically allocate this port too 'tcp-l:127.0.0.1:5999', - f'ws://127.0.0.1:{port}/desktop-websockify/?token={token}', + f'ws://{origin}/desktop-websockify/?token={token}', ] ) try: + # :: is used to indicate port, as that is what VNC expects. + # A single : is used to indicate display number. In our case, we + # do not use multiple displays so no need to specify that. with api.connect('127.0.0.1::5999') as client: + # Wait a couple of seconds for the desktop to fully render time.sleep(5) client.captureScreen("test.jpeg") - assert image_diff( - str(REPO_ROOT / "integration-tests/expected.jpeg"), "test.jpeg" - ) - # subprocess.check_call([ - # 'vncdo', - # '-vv', - # '-s', - # '127.0.0.1::5999', - # 'expect', - # '5' - # ]) - # time.sleep(10) - # subprocess.check_call([ - # 'vncdo', - # '-s', - # '127.0.0.1::5999', - # 'capture', - # 'capture.jpeg' - # ]) + + # This asserts if the images are different, so test will fail + image_diff(str(REPO_ROOT / "integration-tests/expected.jpeg"), "test.jpeg") finally: # Explicitly shutdown vncdo, as otherwise a stray thread keeps # running forever From 00808aa627f2abdfc89422fd64806ba11997bbdd Mon Sep 17 00:00:00 2001 From: YuviPanda Date: Wed, 14 Feb 2024 21:38:11 -0800 Subject: [PATCH 05/23] Use full names for all options --- integration-tests/test_vnc.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/integration-tests/test_vnc.py b/integration-tests/test_vnc.py index 718c6af6..5cfbb1b2 100644 --- a/integration-tests/test_vnc.py +++ b/integration-tests/test_vnc.py @@ -43,9 +43,9 @@ def container(container_image) -> tuple[str, str]: cmd = [ "docker", "run", - "-P", + "--publish-all", "--rm", - "-d", + "--detach", "--security-opt", "seccomp=unconfined", "--security-opt", @@ -72,7 +72,7 @@ def container(container_image) -> tuple[str, str]: try: yield (origin, token) finally: - subprocess.check_call(['docker', 'stop', container_name]) + subprocess.check_call(['docker', 'container', 'stop', container_name]) def test_vnc_screenshot(container, image_diff): From af61281f9b71db58c87f80212cc1a484dc7c5e38 Mon Sep 17 00:00:00 2001 From: YuviPanda Date: Wed, 14 Feb 2024 21:53:18 -0800 Subject: [PATCH 06/23] Explicitly wait for container to be ready --- integration-tests/test_vnc.py | 26 ++++++++++++++++++-------- 1 file changed, 18 insertions(+), 8 deletions(-) diff --git a/integration-tests/test_vnc.py b/integration-tests/test_vnc.py index 5cfbb1b2..6f1cc2d1 100644 --- a/integration-tests/test_vnc.py +++ b/integration-tests/test_vnc.py @@ -57,18 +57,28 @@ def container(container_image) -> tuple[str, str]: ] container_name = subprocess.check_output(cmd).decode().strip() - # FIXME: Instead, wait for container to be ready here - time.sleep(5) - - container_info = json.loads( - subprocess.check_output( - ['docker', 'container', 'inspect', container_name] - ).decode() - ) + print("Waiting for container to come online...") + # Try 5 times, with a 2s wait in between + for current_try in range(5): + container_info = json.loads( + subprocess.check_output( + ['docker', 'container', 'inspect', container_name] + ).decode() + ) + + container_health = container_info[0]["State"]["Health"]["Status"] + if container_health == "healthy": + break + + print(f"Current container health status: {container_health}") + time.sleep(2) + else: + raise TimeoutError("Could not start docker container in time") exposed_port = container_info[0]["NetworkSettings"]["Ports"]["8888/tcp"][0] origin = f"{exposed_port['HostIp']}:{exposed_port['HostPort']}" + print(f"Container started at {origin}") try: yield (origin, token) finally: From cbe3e3d13f3c3100a2e3e057f46c95e4f4b1061b Mon Sep 17 00:00:00 2001 From: YuviPanda Date: Wed, 14 Feb 2024 21:53:40 -0800 Subject: [PATCH 07/23] Dynamically allocate websocat port --- dev-requirements.txt | 1 + integration-tests/test_vnc.py | 7 +++---- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/dev-requirements.txt b/dev-requirements.txt index 3b730ec3..12fa9422 100644 --- a/dev-requirements.txt +++ b/dev-requirements.txt @@ -1,3 +1,4 @@ pytest +pytest-asyncio # Used for unused_tcp_port fixture pytest-image-diff vncdotool diff --git a/integration-tests/test_vnc.py b/integration-tests/test_vnc.py index 6f1cc2d1..d062ef73 100644 --- a/integration-tests/test_vnc.py +++ b/integration-tests/test_vnc.py @@ -85,15 +85,14 @@ def container(container_image) -> tuple[str, str]: subprocess.check_call(['docker', 'container', 'stop', container_name]) -def test_vnc_screenshot(container, image_diff): +def test_vnc_screenshot(container, image_diff, unused_tcp_port): origin, token = container websocat_proc = subprocess.Popen( [ 'websocat', '--binary', '--exit-on-eof', - # FIXME: Dynamically allocate this port too - 'tcp-l:127.0.0.1:5999', + f'tcp-l:127.0.0.1:{unused_tcp_port}', f'ws://{origin}/desktop-websockify/?token={token}', ] ) @@ -101,7 +100,7 @@ def test_vnc_screenshot(container, image_diff): # :: is used to indicate port, as that is what VNC expects. # A single : is used to indicate display number. In our case, we # do not use multiple displays so no need to specify that. - with api.connect('127.0.0.1::5999') as client: + with api.connect(f'127.0.0.1::{unused_tcp_port}') as client: # Wait a couple of seconds for the desktop to fully render time.sleep(5) client.captureScreen("test.jpeg") From 66809bec07108cdfd719974cd6336e463fc299a4 Mon Sep 17 00:00:00 2001 From: YuviPanda Date: Wed, 14 Feb 2024 22:01:54 -0800 Subject: [PATCH 08/23] Quieten wget --- .github/workflows/test.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index f4cc0419..293f223c 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -43,7 +43,7 @@ jobs: run: | pip install -r dev-requirements.txt - wget https://github.com/vi/websocat/releases/download/v1.12.0/websocat.x86_64-unknown-linux-musl \ + 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 From e8b864a2291617bf28cae91261bc1e1200bb5a25 Mon Sep 17 00:00:00 2001 From: YuviPanda Date: Wed, 14 Feb 2024 22:09:54 -0800 Subject: [PATCH 09/23] Cleanup github workflow file --- .github/workflows/test.yaml | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index 293f223c..dbbfd652 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -1,7 +1,7 @@ # This is a GitHub workflow defining a set of jobs with a set of steps. # ref: https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions # -name: Test +name: Integration Tests on: pull_request: @@ -35,6 +35,7 @@ jobs: steps: - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 with: python-version: "3.10" @@ -42,7 +43,7 @@ jobs: - name: Install testing requirements run: | pip install -r dev-requirements.txt - + # Install websocat, needed for integration tests 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 From d13d5bd58ecb81c67146454ea2fec66a27c1fa9c Mon Sep 17 00:00:00 2001 From: YuviPanda Date: Wed, 14 Feb 2024 22:14:22 -0800 Subject: [PATCH 10/23] Don't buffer py.test output --- .github/workflows/test.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index dbbfd652..c68d7139 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -50,4 +50,5 @@ jobs: - name: Run Integration tests run: | + export PYTHONUNBUFFERED=1 py.test integration-tests/ -s \ No newline at end of file From 6e7df3ecb50a91d79c80fef02726cbee1104053b Mon Sep 17 00:00:00 2001 From: YuviPanda Date: Wed, 14 Feb 2024 22:19:10 -0800 Subject: [PATCH 11/23] Put screenshot in a temporary directory --- integration-tests/test_vnc.py | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/integration-tests/test_vnc.py b/integration-tests/test_vnc.py index d062ef73..18167215 100644 --- a/integration-tests/test_vnc.py +++ b/integration-tests/test_vnc.py @@ -5,6 +5,7 @@ import json import secrets import subprocess +import tempfile import time from pathlib import Path @@ -100,13 +101,16 @@ def test_vnc_screenshot(container, image_diff, unused_tcp_port): # :: is used to indicate port, as that is what VNC expects. # A single : is used to indicate display number. In our case, we # do not use multiple displays so no need to specify that. - with api.connect(f'127.0.0.1::{unused_tcp_port}') as client: + with api.connect( + f'127.0.0.1::{unused_tcp_port}' + ) as client, tempfile.TemporaryDirectory() as d: + screenshot_target = Path(d) / "screenshot.jpeg" # Wait a couple of seconds for the desktop to fully render time.sleep(5) - client.captureScreen("test.jpeg") + client.captureScreen(str(screenshot_target)) - # This asserts if the images are different, so test will fail - image_diff(str(REPO_ROOT / "integration-tests/expected.jpeg"), "test.jpeg") + # This asserts if the images are different, so test will fail + image_diff(REPO_ROOT / "integration-tests/expected.jpeg", screenshot_target) finally: # Explicitly shutdown vncdo, as otherwise a stray thread keeps # running forever From 76b801e49faf7541f0b9b3a5d3e477ea9bf37608 Mon Sep 17 00:00:00 2001 From: YuviPanda Date: Wed, 14 Feb 2024 22:19:47 -0800 Subject: [PATCH 12/23] Add a couple more debug log items --- integration-tests/test_vnc.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/integration-tests/test_vnc.py b/integration-tests/test_vnc.py index 18167215..076ca5d5 100644 --- a/integration-tests/test_vnc.py +++ b/integration-tests/test_vnc.py @@ -97,6 +97,7 @@ def test_vnc_screenshot(container, image_diff, unused_tcp_port): f'ws://{origin}/desktop-websockify/?token={token}', ] ) + print(f"websocat proxying 127.0.0.1:{unused_tcp_port} to VNC server") try: # :: is used to indicate port, as that is what VNC expects. # A single : is used to indicate display number. In our case, we @@ -104,9 +105,10 @@ def test_vnc_screenshot(container, image_diff, unused_tcp_port): with api.connect( f'127.0.0.1::{unused_tcp_port}' ) as client, tempfile.TemporaryDirectory() as d: - screenshot_target = Path(d) / "screenshot.jpeg" + print("Connected to VNC server. Attempting to capture screenshot...") # Wait a couple of seconds for the desktop to fully render time.sleep(5) + screenshot_target = Path(d) / "screenshot.jpeg" client.captureScreen(str(screenshot_target)) # This asserts if the images are different, so test will fail From dea42a5f62ace415665129f501a5b5034dac3c87 Mon Sep 17 00:00:00 2001 From: YuviPanda Date: Wed, 14 Feb 2024 22:30:57 -0800 Subject: [PATCH 13/23] Provide container logs in the integration test --- integration-tests/test_vnc.py | 25 ++++++++++++++++++------- 1 file changed, 18 insertions(+), 7 deletions(-) diff --git a/integration-tests/test_vnc.py b/integration-tests/test_vnc.py index 076ca5d5..5d083462 100644 --- a/integration-tests/test_vnc.py +++ b/integration-tests/test_vnc.py @@ -41,12 +41,15 @@ def container(container_image) -> tuple[str, str]: remote container. """ token = secrets.token_hex(16) + container_name = f"remote-desktop-proxy-integration-test-{secrets.token_hex(4)}" cmd = [ "docker", "run", "--publish-all", "--rm", - "--detach", + "-it", + "--name", + container_name, "--security-opt", "seccomp=unconfined", "--security-opt", @@ -56,16 +59,22 @@ def container(container_image) -> tuple[str, str]: "server", f"--IdentityProvider.token={token}", ] - container_name = subprocess.check_output(cmd).decode().strip() + proc = subprocess.Popen(cmd) print("Waiting for container to come online...") # Try 5 times, with a 2s wait in between for current_try in range(5): - container_info = json.loads( - subprocess.check_output( - ['docker', 'container', 'inspect', container_name] - ).decode() - ) + time.sleep(5) + try: + container_info = json.loads( + subprocess.check_output( + ['docker', 'container', 'inspect', container_name] + ).decode() + ) + except subprocess.CalledProcessError as e: + print(f"Container not ready yet, inspect returned {e.returncode}") + time.sleep(2) + continue container_health = container_info[0]["State"]["Health"]["Status"] if container_health == "healthy": @@ -83,6 +92,8 @@ def container(container_image) -> tuple[str, str]: try: yield (origin, token) finally: + proc.kill() + proc.wait() subprocess.check_call(['docker', 'container', 'stop', container_name]) From 3d44feb6be4bd57cd59c8cc8c303ea7374f4f8e6 Mon Sep 17 00:00:00 2001 From: YuviPanda Date: Wed, 14 Feb 2024 22:36:52 -0800 Subject: [PATCH 14/23] Don't pass "-it" to docker run Doesn't actually need to be interactive, we primarily care about sharing stdout --- integration-tests/test_vnc.py | 1 - 1 file changed, 1 deletion(-) diff --git a/integration-tests/test_vnc.py b/integration-tests/test_vnc.py index 5d083462..dd42f663 100644 --- a/integration-tests/test_vnc.py +++ b/integration-tests/test_vnc.py @@ -47,7 +47,6 @@ def container(container_image) -> tuple[str, str]: "run", "--publish-all", "--rm", - "-it", "--name", container_name, "--security-opt", From e8de6d48c2673d48ad61296f8f29df247c5fe78b Mon Sep 17 00:00:00 2001 From: YuviPanda Date: Wed, 14 Feb 2024 22:41:44 -0800 Subject: [PATCH 15/23] Wait longer for capturing screenshot --- integration-tests/test_vnc.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/integration-tests/test_vnc.py b/integration-tests/test_vnc.py index dd42f663..0892af39 100644 --- a/integration-tests/test_vnc.py +++ b/integration-tests/test_vnc.py @@ -117,7 +117,7 @@ def test_vnc_screenshot(container, image_diff, unused_tcp_port): ) as client, tempfile.TemporaryDirectory() as d: print("Connected to VNC server. Attempting to capture screenshot...") # Wait a couple of seconds for the desktop to fully render - time.sleep(5) + time.sleep(15) screenshot_target = Path(d) / "screenshot.jpeg" client.captureScreen(str(screenshot_target)) From b8710d2b0b4bd8d38b12c66def60f3f60965d67e Mon Sep 17 00:00:00 2001 From: YuviPanda Date: Wed, 14 Feb 2024 22:44:17 -0800 Subject: [PATCH 16/23] Document why we are waiting longer here --- integration-tests/test_vnc.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/integration-tests/test_vnc.py b/integration-tests/test_vnc.py index 0892af39..50c395c3 100644 --- a/integration-tests/test_vnc.py +++ b/integration-tests/test_vnc.py @@ -116,7 +116,10 @@ def test_vnc_screenshot(container, image_diff, unused_tcp_port): f'127.0.0.1::{unused_tcp_port}' ) as client, tempfile.TemporaryDirectory() as d: print("Connected to VNC server. Attempting to capture screenshot...") - # Wait a couple of seconds for the desktop to fully render + # Wait a bit for the desktop to fully render, as it is only started + # up when our connect call completes. + # FIXME: Repeatedly take a few screenshots here in a retry loop until + # a timeout or the images match time.sleep(15) screenshot_target = Path(d) / "screenshot.jpeg" client.captureScreen(str(screenshot_target)) From 25bee530927213a417573b9313cf0a92371917b4 Mon Sep 17 00:00:00 2001 From: YuviPanda Date: Wed, 14 Feb 2024 22:45:13 -0800 Subject: [PATCH 17/23] Debug log about attempting to capture screenshot correctly --- integration-tests/test_vnc.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/integration-tests/test_vnc.py b/integration-tests/test_vnc.py index 50c395c3..048ab634 100644 --- a/integration-tests/test_vnc.py +++ b/integration-tests/test_vnc.py @@ -115,13 +115,13 @@ def test_vnc_screenshot(container, image_diff, unused_tcp_port): with api.connect( f'127.0.0.1::{unused_tcp_port}' ) as client, tempfile.TemporaryDirectory() as d: - print("Connected to VNC server. Attempting to capture screenshot...") # Wait a bit for the desktop to fully render, as it is only started # up when our connect call completes. # FIXME: Repeatedly take a few screenshots here in a retry loop until # a timeout or the images match time.sleep(15) screenshot_target = Path(d) / "screenshot.jpeg" + print("Connected to VNC server. Attempting to capture screenshot...") client.captureScreen(str(screenshot_target)) # This asserts if the images are different, so test will fail From 50542059b2d05b18ce432b66424005ac688f2553 Mon Sep 17 00:00:00 2001 From: YuviPanda Date: Wed, 14 Feb 2024 22:45:42 -0800 Subject: [PATCH 18/23] Show full traceback when pytest ends --- .github/workflows/test.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index c68d7139..0844bbca 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -51,4 +51,4 @@ jobs: - name: Run Integration tests run: | export PYTHONUNBUFFERED=1 - py.test integration-tests/ -s \ No newline at end of file + py.test integration-tests/ -s --full-trace \ No newline at end of file From adec7b2828d1bd2ade6a73083eaff42e6bb4bdb2 Mon Sep 17 00:00:00 2001 From: YuviPanda Date: Wed, 14 Feb 2024 22:57:54 -0800 Subject: [PATCH 19/23] Add tests for JS & HTML --- integration-tests/test_vnc.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/integration-tests/test_vnc.py b/integration-tests/test_vnc.py index 048ab634..82d43a5a 100644 --- a/integration-tests/test_vnc.py +++ b/integration-tests/test_vnc.py @@ -8,6 +8,7 @@ import tempfile import time from pathlib import Path +from urllib.request import urlopen import pytest from vncdotool import api @@ -133,3 +134,16 @@ def test_vnc_screenshot(container, image_diff, unused_tcp_port): websocat_proc.kill() websocat_proc.wait() + + +def test_desktop_page(container): + origin, token = container + # Check if the rendered HTML file is returned + desktop_url = f'http://{origin}/desktop/?token={token}' + with urlopen(desktop_url) as f: + assert 'Jupyter Remote Desktop Proxy' in f.read().decode() + + # Check if built JS file is served + js_url = f'http://{origin}/desktop/static/dist/viewer.js?token={token}' + resp = urlopen(js_url) + assert resp.status == 200 From 86c6cdfc2c185b433c299192371599065c9d214b Mon Sep 17 00:00:00 2001 From: YuviPanda Date: Mon, 25 Mar 2024 16:50:57 -0700 Subject: [PATCH 20/23] Try increasing shm size on docker run Maybe this is what github actions needs --- integration-tests/test_vnc.py | 1 + 1 file changed, 1 insertion(+) diff --git a/integration-tests/test_vnc.py b/integration-tests/test_vnc.py index 82d43a5a..70cb5e25 100644 --- a/integration-tests/test_vnc.py +++ b/integration-tests/test_vnc.py @@ -48,6 +48,7 @@ def container(container_image) -> tuple[str, str]: "run", "--publish-all", "--rm", + "--shm-size=512m", "--name", container_name, "--security-opt", From 8ea28eb109f902796e7b44b6cd13ee59b15f0e70 Mon Sep 17 00:00:00 2001 From: YuviPanda Date: Mon, 25 Mar 2024 17:08:39 -0700 Subject: [PATCH 21/23] Revert "Try increasing shm size on docker run" It did nothing This reverts commit a33a447c714955e9b1176192bae3a89d8fd63cb1. --- integration-tests/test_vnc.py | 1 - 1 file changed, 1 deletion(-) diff --git a/integration-tests/test_vnc.py b/integration-tests/test_vnc.py index 70cb5e25..82d43a5a 100644 --- a/integration-tests/test_vnc.py +++ b/integration-tests/test_vnc.py @@ -48,7 +48,6 @@ def container(container_image) -> tuple[str, str]: "run", "--publish-all", "--rm", - "--shm-size=512m", "--name", container_name, "--security-opt", From c285c0ba823d451fa24997acc1acf0ec974f8c87 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Thu, 13 Jun 2024 20:03:35 +0000 Subject: [PATCH 22/23] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- .github/workflows/test.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index 0844bbca..20bd3374 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -51,4 +51,4 @@ jobs: - name: Run Integration tests run: | export PYTHONUNBUFFERED=1 - py.test integration-tests/ -s --full-trace \ No newline at end of file + py.test integration-tests/ -s --full-trace From 7a59e95d20fd576abf4f0a4e1e50915509b30787 Mon Sep 17 00:00:00 2001 From: YuviPanda Date: Thu, 13 Jun 2024 13:06:06 -0700 Subject: [PATCH 23/23] Provide tty for docker run command --- integration-tests/test_vnc.py | 1 + 1 file changed, 1 insertion(+) diff --git a/integration-tests/test_vnc.py b/integration-tests/test_vnc.py index 82d43a5a..50c4910a 100644 --- a/integration-tests/test_vnc.py +++ b/integration-tests/test_vnc.py @@ -48,6 +48,7 @@ def container(container_image) -> tuple[str, str]: "run", "--publish-all", "--rm", + "-it", "--name", container_name, "--security-opt",