Skip to content

Commit 9791623

Browse files
committed
test(webclient): Adds e2e web client test
1 parent fb3abbe commit 9791623

8 files changed

Lines changed: 695 additions & 0 deletions

File tree

.github/workflows/build-ubuntu.yml

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,9 @@ concurrency:
1515
group: ${{ github.workflow }}-${{ github.ref }}
1616
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
1717

18+
permissions:
19+
contents: read
20+
1821
jobs:
1922
build-ubuntu:
2023
# Pin to 22.04 so the wheel is built with GCC 11's libstdc++. The resulting wheel
@@ -459,13 +462,120 @@ jobs:
459462
fi
460463
done
461464
465+
build-webapp:
466+
# GitHub-hosted runner is sufficient: the webxr_client build is pure
467+
# Node/Webpack and produces an artifact consumed by test-cloudxr-web-e2e.
468+
runs-on: ubuntu-22.04
469+
470+
steps:
471+
- name: Checkout code
472+
uses: actions/checkout@v6
473+
with:
474+
fetch-depth: 0
475+
476+
- name: Setup CloudXR SDK
477+
uses: ./.github/actions/setup-cloudxr-sdk
478+
with:
479+
ngc-cli-api-key: ${{ secrets.NGC_TELEOP_CORE_GITHUB_SERVICE_KEY }}
480+
481+
- name: Set up Node
482+
uses: actions/setup-node@v5
483+
with:
484+
node-version: '24'
485+
486+
- name: Install web client dependencies
487+
working-directory: deps/cloudxr/webxr_client
488+
# `npm install` (not `npm ci`) because package-lock.json is gitignored;
489+
# --ignore-scripts skips the @nvidia/cloudxr postinstall (not needed for
490+
# a webpack production build) and avoids any host-specific native steps.
491+
run: npm install --ignore-scripts
492+
493+
- name: Build web client
494+
working-directory: deps/cloudxr/webxr_client
495+
run: npm run build
496+
497+
- name: Upload web client artifact
498+
uses: actions/upload-artifact@v6
499+
with:
500+
name: webxr-client-build
501+
path: deps/cloudxr/webxr_client/build
502+
if-no-files-found: error
503+
retention-days: 1
504+
505+
test-cloudxr-web-e2e:
506+
# GPU runner required: the CloudXR runtime needs an NVIDIA GPU even when
507+
# only one E2E spec is running. Single matrix entry — the web client
508+
# behavior is independent of the matrix dims used by other test jobs.
509+
runs-on: [self-hosted, linux, gpu, x64]
510+
needs:
511+
- build-ubuntu
512+
- build-webapp
513+
514+
steps:
515+
- name: Checkout code
516+
uses: actions/checkout@v6
517+
with:
518+
submodules: recursive
519+
520+
- name: Download install artifacts
521+
uses: actions/download-artifact@v7
522+
with:
523+
name: isaacteleop-install-release-x64-py3.11
524+
525+
- name: Extract tarball to preserve permissions
526+
run: |
527+
mkdir -p install
528+
tar -xvf isaacteleop-install.tar -C install
529+
530+
- name: Download web client artifact
531+
uses: actions/download-artifact@v7
532+
with:
533+
name: webxr-client-build
534+
path: webapp
535+
536+
- name: Set up Docker Buildx
537+
uses: docker/setup-buildx-action@v3
538+
539+
- name: Run web E2E tests
540+
env:
541+
CI: true
542+
ACCEPT_CLOUDXR_EULA: Y
543+
CXR_BUILD_CONTEXT: ${{ github.workspace }}
544+
CXR_WEBAPP_DIR: ${{ github.workspace }}/webapp
545+
PYTHON_VERSION: '3.11'
546+
ISAACTELEOP_PIP_FIND_LINKS: /workspace/install/wheels
547+
ISAACTELEOP_PIP_SPEC: 'isaacteleop[cloudxr]'
548+
ISAACTELEOP_PIP_DEBUG: '0'
549+
run: ./scripts/run_test_e2e_with_web.sh
550+
551+
- name: Upload web E2E artifacts
552+
# always() so green runs also ship streaming.png + video.webm as
553+
# visual proof that the runtime served frames; failure runs
554+
# additionally pick up test-failed-N.png and trace.zip.
555+
if: always()
556+
uses: actions/upload-artifact@v6
557+
with:
558+
name: web-e2e-results
559+
# Compose/runtime logs + Playwright JSON summary + screenshots +
560+
# video recording (every run) + trace zip (failure runs only,
561+
# per playwright.config.ts).
562+
path: |
563+
e2e-logs.txt
564+
tests/web_e2e/test-results/results.json
565+
tests/web_e2e/test-results/**/*.png
566+
tests/web_e2e/test-results/**/*.webm
567+
tests/web_e2e/test-results/**/trace.zip
568+
if-no-files-found: ignore
569+
retention-days: 7
570+
462571
publish-wheel: # to internal Artifactory
463572
runs-on: [self-hosted, linux]
464573
needs:
465574
- build-ubuntu
466575
- test-viz-gpu
467576
- test-cloudxr
468577
- test-teleop-ros2
578+
- test-cloudxr-web-e2e
469579
outputs:
470580
wheel_paths: ${{ steps.upload-artifactory.outputs.wheel_paths }}
471581
environment: dev
@@ -480,6 +590,7 @@ jobs:
480590
&& needs.test-viz-gpu.result == 'success'
481591
&& needs.test-cloudxr.result == 'success'
482592
&& needs.test-teleop-ros2.result == 'success'
593+
&& needs.test-cloudxr-web-e2e.result == 'success'
483594
484595
steps:
485596
- name: Set up Python

.gitignore

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,12 @@
88
node_modules/
99
package-lock.json
1010

11+
# Playwright artifacts (test-results/, traces, HTML report)
12+
test-results/
13+
**/test-results/
14+
playwright-report/
15+
**/playwright-report/
16+
1117
# Build directories
1218
build/
1319
**/build/
Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2+
# SPDX-License-Identifier: Apache-2.0
3+
4+
# Self-contained Docker Compose stack used only by web E2E tests
5+
# (scripts/run_test_e2e_with_web.sh and the test-cloudxr-web-e2e CI job).
6+
# Defines:
7+
# - cloudxr-runtime: the runtime container reusing Dockerfile.runtime, with
8+
# a small entrypoint shim that backgrounds python -m http.server to serve
9+
# the prebuilt webxr_client at port 8080.
10+
# - playwright: the official Playwright image driving the web client.
11+
#
12+
# Both services join the project default user-defined bridge network so the
13+
# playwright container can reach the runtime by service name (cloudxr-runtime).
14+
#
15+
# Use:
16+
# docker compose -p <unique> \
17+
# -f deps/cloudxr/docker-compose.test-e2e.yaml up
18+
#
19+
# This file intentionally duplicates a few lines from docker-compose.runtime.yaml
20+
# (build args) to avoid extending it with !reset tags for the inherited
21+
# network_mode/runtime values that conflict with E2E requirements.
22+
23+
services:
24+
cloudxr-runtime:
25+
build:
26+
context: ${CXR_BUILD_CONTEXT:?CXR_BUILD_CONTEXT must point to repository root}
27+
dockerfile: deps/cloudxr/Dockerfile.runtime
28+
args:
29+
PYTHON_VERSION:
30+
ISAACTELEOP_PIP_SPEC:
31+
ISAACTELEOP_PIP_FIND_LINKS:
32+
ISAACTELEOP_PIP_DEBUG:
33+
container_name: cloudxr-runtime-${COMPOSE_PROJECT_NAME:-isaacteleop-web-e2e}
34+
35+
# No `user:` override: the runtime container is fully ephemeral here (no
36+
# host bind-writes, no shared OpenXR volume with another consumer) so it
37+
# runs as the image-default root. That keeps /openxr/ writable without
38+
# having to seed/chown a named volume to match a runner uid/gid.
39+
40+
# CI hook-based GPU injection: runc + deploy.resources, mirroring the
41+
# pattern in docker-compose.test.yaml. Avoids `runtime: nvidia` which is
42+
# not registered as a Docker runtime on most CI hosts.
43+
runtime: runc
44+
deploy:
45+
resources:
46+
reservations:
47+
devices:
48+
- driver: nvidia
49+
count: all
50+
capabilities: [gpu]
51+
52+
environment:
53+
- ACCEPT_EULA=${ACCEPT_CLOUDXR_EULA:-Y}
54+
- NV_DEVICE_PROFILE=${NV_DEVICE_PROFILE:-Quest3}
55+
- NV_GPU_INDEX=${NV_GPU_INDEX:-0}
56+
57+
volumes:
58+
# /openxr/ stays in the container's own writable layer — Playwright
59+
# never reads it, so there's no need for a shared named volume here
60+
# (and a Docker-managed named volume seeded from the image's
61+
# root-owned /openxr/ would break a non-root container user).
62+
#
63+
# Bind-mount the prebuilt web client so python -m http.server can serve
64+
# it. CI sets CXR_WEBAPP_DIR to the downloaded webxr-client-build artifact
65+
# directory; the default works for local runs after `npm run build`.
66+
- ${CXR_WEBAPP_DIR:-./webxr_client/build}:/app/webapp:ro
67+
68+
healthcheck:
69+
# Verify both the CloudXR runtime ready marker and the static HTTP
70+
# server's TCP port before tests start.
71+
test:
72+
- CMD-SHELL
73+
- >-
74+
test -f /openxr/.cloudxr/run/runtime_started &&
75+
bash -c 'exec 3<>/dev/tcp/localhost/8080'
76+
interval: 2s
77+
timeout: 2s
78+
retries: 30
79+
start_period: 10s
80+
81+
entrypoint: ["/bin/bash", "-lc"]
82+
command:
83+
- |
84+
python -m http.server 8080 --bind 0.0.0.0 --directory /app/webapp >/tmp/http.log 2>&1 &
85+
exec /eula.sh /entrypoint.sh
86+
87+
playwright:
88+
# Tag must match @playwright/test version in tests/web_e2e/package.json;
89+
# mismatched tags break Playwright's bundled-browser path resolution.
90+
image: mcr.microsoft.com/playwright:v1.59.1-noble
91+
# Run as the host user so writes through the /tests bind mount
92+
# (test-results/, package-lock.json, node_modules/) land on the host with
93+
# the runner's ownership. The Playwright image otherwise starts as root.
94+
user: "${PLAYWRIGHT_UID:-1000}:${PLAYWRIGHT_GID:-1000}"
95+
depends_on:
96+
cloudxr-runtime:
97+
condition: service_healthy
98+
working_dir: /tests
99+
volumes:
100+
# Compose paths are relative to this file's directory (deps/cloudxr/);
101+
# ../../tests/web_e2e resolves to <repo-root>/tests/web_e2e/.
102+
- ../../tests/web_e2e:/tests
103+
environment:
104+
- WEB_URL=http://cloudxr-runtime:8080
105+
- CXR_HOST=cloudxr-runtime
106+
# Direct CloudXR signaling port over plain ws:// (matches HTTP page
107+
# protocol), so no TLS/certificate handling is required anywhere in the
108+
# test path. Tests that exercise the wss-proxy would use 48322 with
109+
# WEB_URL switched to https://.
110+
- CXR_PORT=49100
111+
- CI=true
112+
# Image default HOME=/root is not writable by the non-root user we run
113+
# as; redirect HOME so `npm install`'s cache (~/.npm) and any tool
114+
# configs land in a writable tmpfs-style path. Playwright browsers are
115+
# at PLAYWRIGHT_BROWSERS_PATH=/ms-playwright in the image and don't
116+
# depend on HOME.
117+
- HOME=/tmp
118+
entrypoint: ["/bin/bash", "-lc"]
119+
command:
120+
# `npm install` (not `npm ci`) because package-lock.json is gitignored;
121+
# @playwright/test is exact-pinned in package.json and the image tag is
122+
# pinned above, so the install is still effectively reproducible.
123+
- |
124+
npm install --no-audit --no-fund
125+
exec npx playwright test --reporter=list

scripts/run_test_e2e_with_web.sh

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
#!/bin/bash
2+
3+
# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
4+
# SPDX-License-Identifier: Apache-2.0
5+
6+
# Web E2E Test Runner with CloudXR
7+
#
8+
# Brings up the CloudXR runtime + a static HTTP server (serving the prebuilt
9+
# webxr_client) in one container, then runs Playwright against it from a
10+
# second container, then tears the stack down.
11+
#
12+
# Project-scoped via -p so multiple invocations on the same host (e.g.
13+
# parallel CI matrix jobs) do not collide on container/network/volume names.
14+
#
15+
# Usage:
16+
# ./scripts/run_test_e2e_with_web.sh
17+
#
18+
# Required environment:
19+
# CXR_WEBAPP_DIR Absolute path to the prebuilt webxr_client
20+
# build/ directory (bind-mounted into the runtime
21+
# container at /app/webapp).
22+
#
23+
# Optional environment (with defaults):
24+
# PYTHON_VERSION Python version for Dockerfile.runtime (3.11)
25+
# ISAACTELEOP_PIP_FIND_LINKS pip find-links inside Dockerfile.runtime build
26+
# context (/workspace/install/wheels)
27+
# ISAACTELEOP_PIP_SPEC isaacteleop pip spec (isaacteleop[cloudxr])
28+
# ISAACTELEOP_PIP_DEBUG Verbose pip install output (0)
29+
30+
set -euo pipefail
31+
32+
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
33+
GIT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
34+
cd "$GIT_ROOT"
35+
36+
if [ -z "${CXR_WEBAPP_DIR:-}" ]; then
37+
echo "Error: CXR_WEBAPP_DIR is required (absolute path to webxr_client build/ output)" >&2
38+
exit 1
39+
fi
40+
41+
if [ ! -f "$CXR_WEBAPP_DIR/index.html" ]; then
42+
echo "Error: $CXR_WEBAPP_DIR does not contain index.html — pass the webxr_client build/ directory" >&2
43+
exit 1
44+
fi
45+
46+
# Project-scope container names, networks, and Docker-managed volumes across
47+
# parallel jobs. -p namespaces every Compose-created resource.
48+
PROJECT="isaacteleop-web-e2e-${GITHUB_RUN_ID:-local}-${GITHUB_RUN_ATTEMPT:-1}-$(uname -m)"
49+
50+
# Dockerfile.runtime build args (consumed by the build: section in
51+
# docker-compose.test-e2e.yaml).
52+
export CXR_BUILD_CONTEXT="$GIT_ROOT"
53+
export PYTHON_VERSION="${PYTHON_VERSION:-3.11}"
54+
export ISAACTELEOP_PIP_SPEC="${ISAACTELEOP_PIP_SPEC:-isaacteleop[cloudxr]}"
55+
export ISAACTELEOP_PIP_FIND_LINKS="${ISAACTELEOP_PIP_FIND_LINKS:-/workspace/install/wheels}"
56+
export ISAACTELEOP_PIP_DEBUG="${ISAACTELEOP_PIP_DEBUG:-0}"
57+
58+
# Default-on EULA for non-interactive runs; the compose file already defaults
59+
# this but exporting here keeps `docker compose config` output deterministic.
60+
export ACCEPT_CLOUDXR_EULA="${ACCEPT_CLOUDXR_EULA:-Y}"
61+
62+
# Run the Playwright container as the invoking user so writes through the
63+
# tests/web_e2e bind mount (test-results/, package-lock.json) end up with the
64+
# runner's ownership rather than root's. Without this, GitHub Actions cleanup
65+
# between jobs fails to remove the workspace and re-runs (local or CI) hit
66+
# permission errors on the same paths.
67+
export PLAYWRIGHT_UID="$(id -u)"
68+
export PLAYWRIGHT_GID="$(id -g)"
69+
70+
COMPOSE=(
71+
docker compose
72+
-p "$PROJECT"
73+
-f deps/cloudxr/docker-compose.test-e2e.yaml
74+
)
75+
76+
teardown() {
77+
local rc=$?
78+
{
79+
echo "::group::Compose logs"
80+
"${COMPOSE[@]}" logs --no-color > "$GIT_ROOT/e2e-logs.txt" 2>&1 || true
81+
cat "$GIT_ROOT/e2e-logs.txt" || true
82+
echo "::endgroup::"
83+
"${COMPOSE[@]}" down -v --remove-orphans 2>/dev/null || true
84+
} >&2
85+
exit "$rc"
86+
}
87+
trap teardown EXIT
88+
89+
echo "Web E2E project: $PROJECT"
90+
echo "Web app dir: $CXR_WEBAPP_DIR"
91+
echo ""
92+
93+
echo "Building and starting cloudxr-runtime..."
94+
"${COMPOSE[@]}" up --build -d cloudxr-runtime
95+
96+
echo "Waiting for cloudxr-runtime to become healthy..."
97+
healthy=false
98+
for _ in $(seq 1 60); do
99+
status="$("${COMPOSE[@]}" ps cloudxr-runtime --format '{{.Health}}' 2>/dev/null || true)"
100+
case "$status" in
101+
*unhealthy*)
102+
echo "Error: cloudxr-runtime entered unhealthy state" >&2
103+
"${COMPOSE[@]}" logs cloudxr-runtime || true
104+
exit 1
105+
;;
106+
*healthy*)
107+
healthy=true
108+
break
109+
;;
110+
esac
111+
sleep 2
112+
done
113+
114+
if [ "$healthy" != true ]; then
115+
echo "Error: cloudxr-runtime did not become healthy within timeout" >&2
116+
"${COMPOSE[@]}" logs cloudxr-runtime || true
117+
exit 1
118+
fi
119+
120+
echo "Running Playwright spec..."
121+
"${COMPOSE[@]}" run --rm playwright

0 commit comments

Comments
 (0)