From e4c2ec3871683b0b96491a06ec2ba0f13a278539 Mon Sep 17 00:00:00 2001 From: Yanzi Zhu Date: Sat, 25 Apr 2026 15:50:48 -0700 Subject: [PATCH 1/2] test(webclient): Adds e2e web client test --- .github/workflows/build-ubuntu.yml | 110 ++++++++++++++ .gitignore | 6 + deps/cloudxr/docker-compose.test-e2e.yaml | 125 ++++++++++++++++ scripts/run_test_e2e_with_web.sh | 121 +++++++++++++++ tests/web_e2e/README.md | 91 +++++++++++ tests/web_e2e/cloudxr-connect.spec.ts | 174 ++++++++++++++++++++++ tests/web_e2e/package.json | 10 ++ tests/web_e2e/playwright.config.ts | 57 +++++++ 8 files changed, 694 insertions(+) create mode 100644 deps/cloudxr/docker-compose.test-e2e.yaml create mode 100755 scripts/run_test_e2e_with_web.sh create mode 100644 tests/web_e2e/README.md create mode 100644 tests/web_e2e/cloudxr-connect.spec.ts create mode 100644 tests/web_e2e/package.json create mode 100644 tests/web_e2e/playwright.config.ts diff --git a/.github/workflows/build-ubuntu.yml b/.github/workflows/build-ubuntu.yml index 4072e738b..d95cb2806 100644 --- a/.github/workflows/build-ubuntu.yml +++ b/.github/workflows/build-ubuntu.yml @@ -15,6 +15,9 @@ concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: ${{ github.event_name == 'pull_request' }} +permissions: + contents: read + jobs: build-ubuntu: # Pin to 22.04 so the wheel is built with GCC 11's libstdc++. The resulting wheel @@ -631,6 +634,112 @@ jobs: fi done + build-webapp: + # GitHub-hosted runner is sufficient: the webxr_client build is pure + # Node/Webpack and produces an artifact consumed by test-cloudxr-web-e2e. + runs-on: ubuntu-22.04 + + steps: + - name: Checkout code + uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Setup CloudXR SDK + uses: ./.github/actions/setup-cloudxr-sdk + with: + ngc-cli-api-key: ${{ secrets.NGC_TELEOP_CORE_GITHUB_SERVICE_KEY }} + + - name: Set up Node + uses: actions/setup-node@v5 + with: + node-version: '24' + + - name: Install web client dependencies + working-directory: deps/cloudxr/webxr_client + # `npm install` (not `npm ci`) because package-lock.json is gitignored; + # --ignore-scripts skips the @nvidia/cloudxr postinstall (not needed for + # a webpack production build) and avoids any host-specific native steps. + run: npm install --ignore-scripts + + - name: Build web client + working-directory: deps/cloudxr/webxr_client + run: npm run build + + - name: Upload web client artifact + uses: actions/upload-artifact@v6 + with: + name: webxr-client-build + path: deps/cloudxr/webxr_client/build + if-no-files-found: error + retention-days: 1 + + test-cloudxr-web-e2e: + # GPU runner required: the CloudXR runtime needs an NVIDIA GPU even when + # only one E2E spec is running. Single matrix entry — the web client + # behavior is independent of the matrix dims used by other test jobs. + runs-on: [self-hosted, linux, gpu, x64] + needs: + - build-ubuntu + - build-webapp + + steps: + - name: Checkout code + uses: actions/checkout@v6 + with: + submodules: recursive + + - name: Download install artifacts + uses: actions/download-artifact@v7 + with: + name: isaacteleop-install-release-x64-py3.11 + + - name: Extract tarball to preserve permissions + run: | + mkdir -p install + tar -xvf isaacteleop-install.tar -C install + + - name: Download web client artifact + uses: actions/download-artifact@v7 + with: + name: webxr-client-build + path: webapp + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Run web E2E tests + env: + CI: true + ACCEPT_CLOUDXR_EULA: Y + CXR_BUILD_CONTEXT: ${{ github.workspace }} + CXR_WEBAPP_DIR: ${{ github.workspace }}/webapp + PYTHON_VERSION: '3.11' + ISAACTELEOP_PIP_FIND_LINKS: /workspace/install/wheels + ISAACTELEOP_PIP_SPEC: 'isaacteleop[cloudxr]' + ISAACTELEOP_PIP_DEBUG: '0' + run: ./scripts/run_test_e2e_with_web.sh + + - name: Upload web E2E artifacts + # always() so green runs also ship streaming.png + video.webm as + # visual proof that the runtime served frames; failure runs + # additionally pick up test-failed-N.png and trace.zip. + if: always() + uses: actions/upload-artifact@v6 + with: + name: web-e2e-results + # Compose/runtime logs + Playwright JSON summary + screenshots + + # video recording (every run) + trace zip (failure runs only, + # per playwright.config.ts). + path: | + e2e-logs.txt + tests/web_e2e/test-results/results.json + tests/web_e2e/test-results/**/*.png + tests/web_e2e/test-results/**/*.webm + tests/web_e2e/test-results/**/trace.zip + if-no-files-found: ignore + retention-days: 7 + # These are the actual dependency jobs for publish-wheel. publish-wheel # depends only on wheel-gate so the dependency list lives in one place, while # this gate still runs for fork PRs where publish-wheel is intentionally skipped. @@ -643,6 +752,7 @@ jobs: - test-viz-sanitizers - test-cloudxr - test-teleop-ros2 + - test-cloudxr-web-e2e if: ${{ always() }} steps: diff --git a/.gitignore b/.gitignore index 220850c71..f16dc846f 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,12 @@ node_modules/ package-lock.json +# Playwright artifacts (test-results/, traces, HTML report) +test-results/ +**/test-results/ +playwright-report/ +**/playwright-report/ + # Build directories build/ **/build/ diff --git a/deps/cloudxr/docker-compose.test-e2e.yaml b/deps/cloudxr/docker-compose.test-e2e.yaml new file mode 100644 index 000000000..eb2ceed17 --- /dev/null +++ b/deps/cloudxr/docker-compose.test-e2e.yaml @@ -0,0 +1,125 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +# Self-contained Docker Compose stack used only by web E2E tests +# (scripts/run_test_e2e_with_web.sh and the test-cloudxr-web-e2e CI job). +# Defines: +# - cloudxr-runtime: the runtime container reusing Dockerfile.runtime, with +# a small entrypoint shim that backgrounds python -m http.server to serve +# the prebuilt webxr_client at port 8080. +# - playwright: the official Playwright image driving the web client. +# +# Both services join the project default user-defined bridge network so the +# playwright container can reach the runtime by service name (cloudxr-runtime). +# +# Use: +# docker compose -p \ +# -f deps/cloudxr/docker-compose.test-e2e.yaml up +# +# This file intentionally duplicates a few lines from docker-compose.runtime.yaml +# (build args) to avoid extending it with !reset tags for the inherited +# network_mode/runtime values that conflict with E2E requirements. + +services: + cloudxr-runtime: + build: + context: ${CXR_BUILD_CONTEXT:?CXR_BUILD_CONTEXT must point to repository root} + dockerfile: deps/cloudxr/Dockerfile.runtime + args: + PYTHON_VERSION: + ISAACTELEOP_PIP_SPEC: + ISAACTELEOP_PIP_FIND_LINKS: + ISAACTELEOP_PIP_DEBUG: + container_name: cloudxr-runtime-${COMPOSE_PROJECT_NAME:-isaacteleop-web-e2e} + + # No `user:` override: the runtime container is fully ephemeral here (no + # host bind-writes, no shared OpenXR volume with another consumer) so it + # runs as the image-default root. That keeps /openxr/ writable without + # having to seed/chown a named volume to match a runner uid/gid. + + # CI hook-based GPU injection: runc + deploy.resources, mirroring the + # pattern in docker-compose.test.yaml. Avoids `runtime: nvidia` which is + # not registered as a Docker runtime on most CI hosts. + runtime: runc + deploy: + resources: + reservations: + devices: + - driver: nvidia + count: all + capabilities: [gpu] + + environment: + - ACCEPT_EULA=${ACCEPT_CLOUDXR_EULA:-Y} + - NV_DEVICE_PROFILE=${NV_DEVICE_PROFILE:-Quest3} + - NV_GPU_INDEX=${NV_GPU_INDEX:-0} + + volumes: + # /openxr/ stays in the container's own writable layer — Playwright + # never reads it, so there's no need for a shared named volume here + # (and a Docker-managed named volume seeded from the image's + # root-owned /openxr/ would break a non-root container user). + # + # Bind-mount the prebuilt web client so python -m http.server can serve + # it. CI sets CXR_WEBAPP_DIR to the downloaded webxr-client-build artifact + # directory; the default works for local runs after `npm run build`. + - ${CXR_WEBAPP_DIR:-./webxr_client/build}:/app/webapp:ro + + healthcheck: + # Verify both the CloudXR runtime ready marker and the static HTTP + # server's TCP port before tests start. + test: + - CMD-SHELL + - >- + test -f /openxr/.cloudxr/run/runtime_started && + bash -c 'exec 3<>/dev/tcp/localhost/8080' + interval: 2s + timeout: 2s + retries: 30 + start_period: 10s + + entrypoint: ["/bin/bash", "-lc"] + command: + - | + python -m http.server 8080 --bind 0.0.0.0 --directory /app/webapp >/tmp/http.log 2>&1 & + exec /eula.sh /entrypoint.sh + + playwright: + # Tag must match @playwright/test version in tests/web_e2e/package.json; + # mismatched tags break Playwright's bundled-browser path resolution. + image: mcr.microsoft.com/playwright:v1.59.1-noble + # Run as the host user so writes through the /tests bind mount + # (test-results/, package-lock.json, node_modules/) land on the host with + # the runner's ownership. The Playwright image otherwise starts as root. + user: "${PLAYWRIGHT_UID:-1000}:${PLAYWRIGHT_GID:-1000}" + depends_on: + cloudxr-runtime: + condition: service_healthy + working_dir: /tests + volumes: + # Compose paths are relative to this file's directory (deps/cloudxr/); + # ../../tests/web_e2e resolves to /tests/web_e2e/. + - ../../tests/web_e2e:/tests + environment: + - WEB_URL=http://cloudxr-runtime:8080 + - CXR_HOST=cloudxr-runtime + # Direct CloudXR signaling port over plain ws:// (matches HTTP page + # protocol), so no TLS/certificate handling is required anywhere in the + # test path. Tests that exercise the wss-proxy would use 48322 with + # WEB_URL switched to https://. + - CXR_PORT=49100 + - CI=true + # Image default HOME=/root is not writable by the non-root user we run + # as; redirect HOME so `npm install`'s cache (~/.npm) and any tool + # configs land in a writable tmpfs-style path. Playwright browsers are + # at PLAYWRIGHT_BROWSERS_PATH=/ms-playwright in the image and don't + # depend on HOME. + - HOME=/tmp + entrypoint: ["/bin/bash", "-lc"] + command: + # `npm install` (not `npm ci`) because package-lock.json is gitignored; + # @playwright/test is exact-pinned in package.json and the image tag is + # pinned above, so the install is still effectively reproducible. + - | + npm install --no-audit --no-fund + exec npx playwright test --reporter=list diff --git a/scripts/run_test_e2e_with_web.sh b/scripts/run_test_e2e_with_web.sh new file mode 100755 index 000000000..3500a85ff --- /dev/null +++ b/scripts/run_test_e2e_with_web.sh @@ -0,0 +1,121 @@ +#!/bin/bash + +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +# Web E2E Test Runner with CloudXR +# +# Brings up the CloudXR runtime + a static HTTP server (serving the prebuilt +# webxr_client) in one container, then runs Playwright against it from a +# second container, then tears the stack down. +# +# Project-scoped via -p so multiple invocations on the same host (e.g. +# parallel CI matrix jobs) do not collide on container/network/volume names. +# +# Usage: +# ./scripts/run_test_e2e_with_web.sh +# +# Required environment: +# CXR_WEBAPP_DIR Absolute path to the prebuilt webxr_client +# build/ directory (bind-mounted into the runtime +# container at /app/webapp). +# +# Optional environment (with defaults): +# PYTHON_VERSION Python version for Dockerfile.runtime (3.11) +# ISAACTELEOP_PIP_FIND_LINKS pip find-links inside Dockerfile.runtime build +# context (/workspace/install/wheels) +# ISAACTELEOP_PIP_SPEC isaacteleop pip spec (isaacteleop[cloudxr]) +# ISAACTELEOP_PIP_DEBUG Verbose pip install output (0) + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +GIT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +cd "$GIT_ROOT" + +if [ -z "${CXR_WEBAPP_DIR:-}" ]; then + echo "Error: CXR_WEBAPP_DIR is required (absolute path to webxr_client build/ output)" >&2 + exit 1 +fi + +if [ ! -f "$CXR_WEBAPP_DIR/index.html" ]; then + echo "Error: $CXR_WEBAPP_DIR does not contain index.html — pass the webxr_client build/ directory" >&2 + exit 1 +fi + +# Project-scope container names, networks, and Docker-managed volumes across +# parallel jobs. -p namespaces every Compose-created resource. +PROJECT="isaacteleop-web-e2e-${GITHUB_RUN_ID:-local}-${GITHUB_RUN_ATTEMPT:-1}-$(uname -m)" + +# Dockerfile.runtime build args (consumed by the build: section in +# docker-compose.test-e2e.yaml). +export CXR_BUILD_CONTEXT="$GIT_ROOT" +export PYTHON_VERSION="${PYTHON_VERSION:-3.11}" +export ISAACTELEOP_PIP_SPEC="${ISAACTELEOP_PIP_SPEC:-isaacteleop[cloudxr]}" +export ISAACTELEOP_PIP_FIND_LINKS="${ISAACTELEOP_PIP_FIND_LINKS:-/workspace/install/wheels}" +export ISAACTELEOP_PIP_DEBUG="${ISAACTELEOP_PIP_DEBUG:-0}" + +# Default-on EULA for non-interactive runs; the compose file already defaults +# this but exporting here keeps `docker compose config` output deterministic. +export ACCEPT_CLOUDXR_EULA="${ACCEPT_CLOUDXR_EULA:-Y}" + +# Run the Playwright container as the invoking user so writes through the +# tests/web_e2e bind mount (test-results/, package-lock.json) end up with the +# runner's ownership rather than root's. Without this, GitHub Actions cleanup +# between jobs fails to remove the workspace and re-runs (local or CI) hit +# permission errors on the same paths. +export PLAYWRIGHT_UID="$(id -u)" +export PLAYWRIGHT_GID="$(id -g)" + +COMPOSE=( + docker compose + -p "$PROJECT" + -f deps/cloudxr/docker-compose.test-e2e.yaml +) + +teardown() { + local rc=$? + { + echo "::group::Compose logs" + "${COMPOSE[@]}" logs --no-color > "$GIT_ROOT/e2e-logs.txt" 2>&1 || true + cat "$GIT_ROOT/e2e-logs.txt" || true + echo "::endgroup::" + "${COMPOSE[@]}" down -v --remove-orphans 2>/dev/null || true + } >&2 + exit "$rc" +} +trap teardown EXIT + +echo "Web E2E project: $PROJECT" +echo "Web app dir: $CXR_WEBAPP_DIR" +echo "" + +echo "Building and starting cloudxr-runtime..." +"${COMPOSE[@]}" up --build -d cloudxr-runtime + +echo "Waiting for cloudxr-runtime to become healthy..." +healthy=false +for _ in $(seq 1 60); do + status="$("${COMPOSE[@]}" ps cloudxr-runtime --format '{{.Health}}' 2>/dev/null || true)" + case "$status" in + *unhealthy*) + echo "Error: cloudxr-runtime entered unhealthy state" >&2 + "${COMPOSE[@]}" logs cloudxr-runtime || true + exit 1 + ;; + *healthy*) + healthy=true + break + ;; + esac + sleep 2 +done + +if [ "$healthy" != true ]; then + echo "Error: cloudxr-runtime did not become healthy within timeout" >&2 + "${COMPOSE[@]}" logs cloudxr-runtime || true + exit 1 +fi + +echo "Running Playwright spec..." +"${COMPOSE[@]}" run --rm playwright diff --git a/tests/web_e2e/README.md b/tests/web_e2e/README.md new file mode 100644 index 000000000..a14a69b56 --- /dev/null +++ b/tests/web_e2e/README.md @@ -0,0 +1,91 @@ + + +# Web Client End-to-End Tests + +Playwright spec that drives the prebuilt `webxr_client` against a live CloudXR +runtime. + +## What it verifies + +[`cloudxr-connect.spec.ts`](./cloudxr-connect.spec.ts) navigates the web client +with `?serverIP=&port=`, then asserts: + +1. SDK initialized (`#errorMessageText` says `"CloudXR.js SDK is supported."`). +2. CONNECT button enabled and clickable. +3. `#startButton` flips to `"CONNECT (XR session active)"` — IWER mock + `requestSession()` resolved. +4. **A WebSocket frame is received from the runtime** (via + `page.on('websocket')` + `framereceived`) — proof of an actual round-trip, + not just a client-side IWER session. +5. Session label remains stable for 3 s (no torn handshake). + +No `webxr_client` source / test-hook changes; everything reads existing DOM +plus the standard browser `WebSocket` API. + +## How CI runs it + +Three jobs in [`.github/workflows/build-ubuntu.yml`](../../.github/workflows/build-ubuntu.yml): + +1. `build-ubuntu` — produces `isaacteleop-install-release-x64-py3.11`. +2. `build-webapp` — `npm run build` in `webxr_client/`, produces + `webxr-client-build`. +3. `test-cloudxr-web-e2e` — runs on `[self-hosted, linux, gpu, x64]`, + downloads both artifacts and executes + [`scripts/run_test_e2e_with_web.sh`](../../scripts/run_test_e2e_with_web.sh), + which `docker compose up`s + [`docker-compose.test-e2e.yaml`](../../deps/cloudxr/docker-compose.test-e2e.yaml) + (cloudxr-runtime + playwright on a project bridge network), waits for + health, runs the spec, tears down. `publish-wheel` gates on this job. + +Project-scoped (`docker compose -p ...-${GITHUB_RUN_ID}-...`) and +Docker-managed named volumes — safe for parallel runs on one GPU host. + +## Run locally + +Requires a reachable CloudXR runtime. Easiest path on macOS or any Linux box +without GPU passthrough: + +```bash +# 1. Serve the webapp (dev-server is fine — pick one). +( cd deps/cloudxr/webxr_client && npm install && npm run dev-server ) & +# Or, to mirror CI exactly: +# ( cd deps/cloudxr/webxr_client && npm install && npm run build ) +# ( cd deps/cloudxr/webxr_client/build && python3 -m http.server 8080 ) & + +# 2. Run the spec (host bundled Chromium, not the compose container). +cd tests/web_e2e +npm install && npx playwright install chromium + +WEB_URL=http://localhost:8080 \ +CXR_HOST= \ +CXR_PORT=49100 \ + npx playwright test --reporter=list +``` + +For the full CI-equivalent flow (Linux + NVIDIA GPU + nvidia-container-toolkit +required): + +```bash +export CXR_WEBAPP_DIR="$PWD/deps/cloudxr/webxr_client/build" +./scripts/run_test_e2e_with_web.sh +``` + +## Env vars + +| Var | Default | Purpose | +|---|---|---| +| `WEB_URL` | `http://cloudxr-runtime:8080` | Page baseURL for `page.goto()`. | +| `CXR_HOST` | `cloudxr-runtime` | Address the browser dials over ws://. | +| `CXR_PORT` | `49100` | Direct CloudXR signaling port (plain ws://). | +| `PW_CHANNEL` | _(unset)_ | Optional: `chrome`, `msedge`, etc., to use a system browser. | + +## Useful flags + +```bash +npx playwright test --headed # watch the browser +npx playwright test --ui # interactive runner +npx playwright show-trace test-results/.../trace.zip # post-mortem +``` diff --git a/tests/web_e2e/cloudxr-connect.spec.ts b/tests/web_e2e/cloudxr-connect.spec.ts new file mode 100644 index 000000000..f3e1c040e --- /dev/null +++ b/tests/web_e2e/cloudxr-connect.spec.ts @@ -0,0 +1,174 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { test, expect } from '@playwright/test'; + +// E2E coverage for the CloudXR web client streaming against a live runtime. +// +// The web client renders most of its UI inside the WebGL canvas via +// @react-three/uikit, which is not queryable as DOM. We therefore assert +// against the regular HTML elements managed by CloudXR2DUI in +// deps/cloudxr/webxr_client/src/index.html: +// - #errorMessageText (status banner) +// - #startButton (CONNECT button) +// +// IWER (loaded automatically when navigator.xr cannot satisfy +// isSessionSupported('immersive-vr'/'immersive-ar')) installs a mock XR device +// in headless Chromium so requestSession() resolves and the React XR store +// transitions into immersive-* mode. +// +// Important: #startButton flipping to "CONNECT (XR session active)" only +// proves IWER's mock session started — it fires before any traffic reaches +// the runtime. To prove an actual round-trip with the CloudXR runtime we +// observe the page-opened WebSocket directly via page.on('websocket') and +// require N inbound frames from the server end of the signaling channel. +// +// NOTE: the /sign_in WS is NOT a sustained channel. It carries the SDP +// answer + ICE candidates (typically 3-5 inbound frames) and then the +// server closes it cleanly. Video subsequently flows over WebRTC RTP / +// data channels, which are not visible to page.on('websocket'). So we set +// a low frame threshold (covers SDP + at least one ICE candidate) and +// treat a clean close after that as success. +const REQUIRED_RUNTIME_FRAMES = 3; + +// Time to allow WebRTC to negotiate, decode, and render the first video +// frames into the WebGL canvas after signaling completes. Used both as the +// stability window and as the lead-in for the visual-evidence screenshot. +const POST_SIGNALING_RENDER_MS = 8_000; + +test('cloudxr web client streams from runtime', async ({ page }) => { + // Plain ws:// to the runtime: the page is served over http://, so + // CloudXR2DUI.getDefaultConfiguration() picks useSecure=false and connects + // to ws://:49100/... directly. No TLS, no certs, no wss-proxy. + const cxrHost = process.env.CXR_HOST ?? 'cloudxr-runtime'; + const cxrPort = process.env.CXR_PORT ?? '49100'; + const url = `/?serverIP=${cxrHost}&port=${cxrPort}`; + + // Match either ws:// or wss:// to the configured runtime host:port. The + // SDK appends its own path (e.g. /signaling/...), so anchor on host:port. + const cxrSocketUrl = new RegExp(`^wss?://${cxrHost}:${cxrPort}(/|$)`); + + // Latch state for the first runtime-bound WebSocket the page opens. Counter + // is read inside error paths to surface partial-progress information when + // the socket closes before reaching REQUIRED_RUNTIME_FRAMES. + let runtimeFramesSeen = 0; + let runtimeFramesResolve: ((url: string) => void) | undefined; + let runtimeFramesReject: ((reason: Error) => void) | undefined; + const runtimeFrames = new Promise((resolve, reject) => { + runtimeFramesResolve = resolve; + runtimeFramesReject = reject; + }); + + page.on('websocket', ws => { + if (!cxrSocketUrl.test(ws.url())) return; + ws.on('framereceived', () => { + runtimeFramesSeen += 1; + if (runtimeFramesSeen >= REQUIRED_RUNTIME_FRAMES) runtimeFramesResolve?.(ws.url()); + }); + ws.on('socketerror', err => runtimeFramesReject?.(new Error( + `WS error on ${ws.url()} after ${runtimeFramesSeen}/${REQUIRED_RUNTIME_FRAMES} frames: ${err}`, + ))); + ws.on('close', () => runtimeFramesReject?.(new Error( + `WS closed after ${runtimeFramesSeen}/${REQUIRED_RUNTIME_FRAMES} frames: ${ws.url()}`, + ))); + }); + + await page.goto(url); + + // Capabilities pass: confirms IWER injected a working navigator.xr and the + // CloudXR JS SDK initialized successfully. Generous timeout covers the + // unpkg.com IWER fetch on first load. + await expect(page.locator('#errorMessageText')).toContainText( + 'CloudXR.js SDK is supported.', + { timeout: 30_000 }, + ); + + // The CONNECT button is enabled once the capability gate completes. + const button = page.locator('#startButton'); + await expect(button).toBeEnabled({ timeout: 15_000 }); + await button.click(); + + // Set by App.tsx when the @react-three/xr store enters immersive-vr/ar mode. + // Necessary precondition (IWER session started), but not sufficient on its + // own — the WS framereceived check below is what proves real connectivity. + await expect(button).toHaveText('CONNECT (XR session active)', { timeout: 30_000 }); + + // Real connectivity gate: REQUIRED_RUNTIME_FRAMES inbound frames means the + // runtime accepted the upgrade and is sustaining traffic. Resolves on the + // Nth frame; rejects on early close / socket error with a partial-progress + // count so failures fail loudly instead of silently passing. + const runtimeUrl = await Promise.race([ + runtimeFrames, + new Promise((_, reject) => + setTimeout( + () => reject(new Error( + `Only received ${runtimeFramesSeen}/${REQUIRED_RUNTIME_FRAMES} WebSocket frames ` + + `from ${cxrHost}:${cxrPort} within 30s`, + )), + 30_000, + ), + ), + ]); + expect(runtimeUrl).toMatch(cxrSocketUrl); + + // Combined stability + render window: signaling has just closed cleanly, + // and WebRTC needs time to negotiate, decode, and render the first video + // frames into the WebGL canvas. The label staying as 'CONNECT (XR session + // active)' through this window also confirms the XR store didn't tear + // back to plain 'CONNECT' after a botched handshake. + await page.waitForTimeout(POST_SIGNALING_RENDER_MS); + await expect(button).toHaveText('CONNECT (XR session active)'); + + // Switch IWER's primary input mode from controller to hand and animate + // the right hand through a small stepped trajectory (translate + yaw). + // Exercises the hand-tracking pose path through the XRDevice -> + // XRSession -> @react-three/xr pipeline across multiple frames (a + // single-step set could be missed by the WebRTC datachannel cadence), + // and produces a visibly moving hand in the recorded video.webm. + // Uses the public XRDevice API exposed by helpers/LoadIWER.ts on + // window.xrDevice — no webxr_client source changes required. + await page.evaluate(async () => { + const device = (window as { xrDevice?: { + controlMode: string; + primaryInputMode: 'controller' | 'hand'; + hands: { right?: { + position: { x: number; y: number; z: number; set: (x: number, y: number, z: number) => void }; + quaternion: { set: (x: number, y: number, z: number, w: number) => void }; + } }; + notifyStateChange?: () => void; + } }).xrDevice; + if (!device) return; + device.controlMode = 'programmatic'; + device.primaryInputMode = 'hand'; + // Both hands are created in XRDevice's ctor and start with connected=true + // (XRTrackedInput.js), so flipping primaryInputMode is enough to make + // them show up in activeInputs/inputSources. + const right = device.hands.right; + if (!right) return; + + const sleep = (ms: number) => new Promise(r => setTimeout(r, ms)); + const STEPS = 10; + const STEP_DELAY_MS = 50; // ~20 Hz: well under runtime frame rate + const dxPerStep = 0.02; // total +0.20 m to the right + const dyPerStep = 0.01; // total +0.10 m up + const dyawPerStep = 0.03; // total +0.30 rad (~17°) yaw + + const x0 = right.position.x; + const y0 = right.position.y; + const z0 = right.position.z; + + for (let i = 1; i <= STEPS; i++) { + right.position.set(x0 + dxPerStep * i, y0 + dyPerStep * i, z0); + const half = (dyawPerStep * i) / 2; + right.quaternion.set(0, Math.sin(half), 0, Math.cos(half)); + device.notifyStateChange?.(); + await sleep(STEP_DELAY_MS); + } + }); + + // Visual confirmation: streamed scene with hand-tracking active and the + // right hand visibly rotated. Always saved (alongside the auto + // on-failure shot) so green runs also ship visual evidence in the + // web-e2e-results CI artifact. + await page.screenshot({ path: 'test-results/streaming.png', fullPage: false }); +}); diff --git a/tests/web_e2e/package.json b/tests/web_e2e/package.json new file mode 100644 index 000000000..52df3cee5 --- /dev/null +++ b/tests/web_e2e/package.json @@ -0,0 +1,10 @@ +{ + "name": "isaacteleop-web-e2e", + "version": "1.0.0", + "private": true, + "description": "Isaac Teleop web client end-to-end tests (Playwright).", + "license": "Apache-2.0", + "devDependencies": { + "@playwright/test": "1.59.1" + } +} diff --git a/tests/web_e2e/playwright.config.ts b/tests/web_e2e/playwright.config.ts new file mode 100644 index 000000000..838680424 --- /dev/null +++ b/tests/web_e2e/playwright.config.ts @@ -0,0 +1,57 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { defineConfig, devices } from '@playwright/test'; + +const baseURL = process.env.WEB_URL ?? 'http://cloudxr-runtime:8080'; + +export default defineConfig({ + testDir: '.', + timeout: 90_000, + expect: { timeout: 15_000 }, + // Catch stray test.only commits in CI runs. + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 1 : 0, + // Artifact policy: + // - results.json: structured pass/fail summary on every run. + // - video.webm: recorded on every run (visual confirmation of + // streaming, not just a failure aid). + // - trace.zip: only when a test fails, since it's the largest blob. + // - screenshot: only-on-failure (the spec also takes an explicit + // streaming.png unconditionally for green-run evidence). + reporter: [ + ['list'], + ['json', { outputFile: 'test-results/results.json' }], + ], + use: { + baseURL, + trace: 'retain-on-failure', + video: 'on', + screenshot: 'only-on-failure', + launchOptions: { + args: [ + '--no-sandbox', + '--disable-setuid-sandbox', + '--ignore-gpu-blocklist', + '--enable-webgl', + // Disable WebXR so Chromium does not advertise navigator.xr from a + // host-registered OpenXR runtime; helpers/LoadIWER.ts then falls + // back to IWER's mock XRDevice, which is what the spec relies on. + '--disable-features=WebXR', + // Force a CPU-rendered GL stack so @react-three/fiber's + // WebGLRenderer can always create a context, even without GPU + // access in the Playwright container or on a headless macOS host. + '--use-angle=swiftshader', + '--use-gl=angle', + '--enable-gpu-rasterization', + '--in-process-gpu', + ], + }, + }, + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'] }, + }, + ], +}); From 051f0a05ae50636b088d4943021ee8fd0ce9ad19 Mon Sep 17 00:00:00 2001 From: yanziz-nv Date: Thu, 30 Apr 2026 15:12:53 -0700 Subject: [PATCH 2/2] feat(webclient): Updates to only run when there are changes --- .github/workflows/build-ubuntu.yml | 48 ++++++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/.github/workflows/build-ubuntu.yml b/.github/workflows/build-ubuntu.yml index d95cb2806..1c81bc852 100644 --- a/.github/workflows/build-ubuntu.yml +++ b/.github/workflows/build-ubuntu.yml @@ -19,6 +19,33 @@ permissions: contents: read jobs: + detect-changes: + # Lightweight gating job: emits a `web` output consumed by build-webapp + # and test-cloudxr-web-e2e so PRs that don't touch the web stack skip + # the GPU-runner E2E pipeline. On push/tag events (main, release/*, + # v*) we always set web=true so protected branches retain full coverage. + runs-on: ubuntu-22.04 + outputs: + web: ${{ steps.filter.outputs.web || 'true' }} + steps: + - name: Filter web paths + # paths-filter@v3 uses the GitHub API on pull_request events and + # does not need a prior actions/checkout. Skipped on non-PR events + # so the `|| 'true'` fallback above takes effect. + if: github.event_name == 'pull_request' + id: filter + uses: dorny/paths-filter@v3 + with: + filters: | + web: + - 'deps/cloudxr/webxr_client/**' + - 'deps/cloudxr/docker-compose.test-e2e.yaml' + - 'deps/cloudxr/Dockerfile.runtime' + - 'tests/web_e2e/**' + - 'scripts/run_test_e2e_with_web.sh' + - '.github/workflows/build-ubuntu.yml' + - '.github/actions/setup-cloudxr-sdk/**' + build-ubuntu: # Pin to 22.04 so the wheel is built with GCC 11's libstdc++. The resulting wheel # then runs on both Ubuntu 22.04 and 24.04 (24.04's libstdc++ is backward compatible). @@ -637,7 +664,10 @@ jobs: build-webapp: # GitHub-hosted runner is sufficient: the webxr_client build is pure # Node/Webpack and produces an artifact consumed by test-cloudxr-web-e2e. + # Skipped on PRs that don't touch the web stack — see detect-changes. runs-on: ubuntu-22.04 + needs: detect-changes + if: needs.detect-changes.outputs.web == 'true' steps: - name: Checkout code @@ -678,10 +708,16 @@ jobs: # GPU runner required: the CloudXR runtime needs an NVIDIA GPU even when # only one E2E spec is running. Single matrix entry — the web client # behavior is independent of the matrix dims used by other test jobs. + # Pinned to x64 because we install the x64-py3.11 wheel artifact below; + # an arm64 runner would make pip reject every wheel on platform tag and + # fail with "No matching distribution found". + # Skipped on PRs that don't touch the web stack — see detect-changes. runs-on: [self-hosted, linux, gpu, x64] needs: + - detect-changes - build-ubuntu - build-webapp + if: needs.detect-changes.outputs.web == 'true' steps: - name: Checkout code @@ -699,6 +735,18 @@ jobs: mkdir -p install tar -xvf isaacteleop-install.tar -C install + - name: Sanity-check wheel artifact + # Surfaces "wrong-arch / missing wheel" up front. Without this, the + # Dockerfile.runtime pip step fails with a noisy "No matching + # distribution found" that masks the real cause (platform-tag + # mismatch when the runner arch doesn't match the wheel artifact). + run: | + ls -la install/wheels/ + test -n "$(ls install/wheels/isaacteleop-*.whl 2>/dev/null)" || { + echo "::error::No isaacteleop wheel found in install/wheels/" + exit 1 + } + - name: Download web client artifact uses: actions/download-artifact@v7 with: