From bb307cd0175fbcef3e46fa0462e147f10687308c Mon Sep 17 00:00:00 2001 From: Yanzi Zhu Date: Thu, 29 Oct 2026 10:33:21 -0700 Subject: [PATCH 1/3] fix(webxr): verify desktop XR emulator readiness Signed-off-by: Yanzi Zhu --- .../webxr_client/helpers/LoadIWER.test.ts | 30 ++++++++++++++++++- deps/cloudxr/webxr_client/helpers/LoadIWER.ts | 18 ++++++++--- 2 files changed, 43 insertions(+), 5 deletions(-) diff --git a/deps/cloudxr/webxr_client/helpers/LoadIWER.test.ts b/deps/cloudxr/webxr_client/helpers/LoadIWER.test.ts index 5540a02655..67ba4a0486 100644 --- a/deps/cloudxr/webxr_client/helpers/LoadIWER.test.ts +++ b/deps/cloudxr/webxr_client/helpers/LoadIWER.test.ts @@ -36,7 +36,15 @@ describe('loadIWERIfNeeded', () => { ({ loadIWERIfNeeded } = require('./LoadIWER')); ({ XRDevice } = jest.requireMock('iwer')); ({ DevUI } = jest.requireMock('@iwer/devui')); - device = { installDevUI: jest.fn(), installRuntime: jest.fn() }; + device = { + installDevUI: jest.fn(), + installRuntime: jest.fn(() => { + Object.defineProperty(navigator, 'xr', { + configurable: true, + value: { isSessionSupported: jest.fn().mockResolvedValue(true) }, + }); + }), + }; XRDevice.mockReturnValue(device); }); @@ -91,6 +99,26 @@ describe('loadIWERIfNeeded', () => { expect(device.installRuntime).toHaveBeenCalledTimes(1); }); + it('rejects a skipped installation and retries when the runtime becomes available', async () => { + const nativeXR = { isSessionSupported: jest.fn().mockResolvedValue(false) }; + Object.defineProperty(navigator, 'xr', { configurable: true, value: nativeXR }); + device.installRuntime.mockImplementationOnce(() => {}); + + await expect(loadIWERIfNeeded()).resolves.toEqual({ + supportsImmersive: false, + iwerLoaded: false, + }); + expect(navigator.xr).toBe(nativeXR); + expect(window.xrDevice).toBeUndefined(); + + await expect(loadIWERIfNeeded()).resolves.toEqual({ + supportsImmersive: true, + iwerLoaded: true, + }); + expect(window.xrDevice).toBe(device); + expect(device.installRuntime).toHaveBeenCalledTimes(2); + }); + it('retries a failed runtime installation', async () => { device.installRuntime.mockRejectedValueOnce(new Error('transient failure')); await expect(loadIWERIfNeeded()).resolves.toEqual({ diff --git a/deps/cloudxr/webxr_client/helpers/LoadIWER.ts b/deps/cloudxr/webxr_client/helpers/LoadIWER.ts index d4f582c84e..c7634f3db9 100644 --- a/deps/cloudxr/webxr_client/helpers/LoadIWER.ts +++ b/deps/cloudxr/webxr_client/helpers/LoadIWER.ts @@ -71,10 +71,20 @@ async function loadIWER(): Promise { // The native XRSystem has already reported no immersive support. await device.installRuntime({ forceInstall: true }); - window.xrDevice = device; - supportsImmersive = true; - iwerLoaded = true; - console.info('IWER runtime installed.'); + // Installation can return without replacing navigator.xr. Verify support + // before reporting readiness to enable Connect. + const [vr, ar] = await Promise.all([ + navigator.xr?.isSessionSupported('immersive-vr'), + navigator.xr?.isSessionSupported('immersive-ar'), + ]); + supportsImmersive = Boolean(vr || ar); + if (supportsImmersive) { + window.xrDevice = device; + iwerLoaded = true; + console.info('IWER runtime installed.'); + } else { + console.warn('IWER runtime installed without immersive session support.'); + } } catch (error) { console.warn('IWER runtime install failed:', error); } From 4b7b83a7845b87e89870cbd2ac628ea6143c4ee2 Mon Sep 17 00:00:00 2001 From: Yanzi Zhu Date: Thu, 29 Oct 2026 11:15:07 -0700 Subject: [PATCH 2/3] fix(webxr): gate Connect on completed startup checks Signed-off-by: Yanzi Zhu --- deps/cloudxr/webxr_client/AGENTS.md | 4 ++ deps/cloudxr/webxr_client/src/App.tsx | 4 ++ .../webxr_client/src/CloudXR2DUI.test.ts | 58 +++++++++++++++++++ deps/cloudxr/webxr_client/src/CloudXR2DUI.tsx | 16 +++-- .../tests/playwright/AppMockTest.spec.js | 16 ----- .../playwright/ClientUIStatesTest.spec.js | 24 ++------ .../tests/playwright/ConnectReadiness.spec.js | 49 ++++++++++++++++ 7 files changed, 133 insertions(+), 38 deletions(-) create mode 100644 deps/cloudxr/webxr_client/tests/playwright/ConnectReadiness.spec.js diff --git a/deps/cloudxr/webxr_client/AGENTS.md b/deps/cloudxr/webxr_client/AGENTS.md index 0b10dd5b99..9e2e8a3140 100644 --- a/deps/cloudxr/webxr_client/AGENTS.md +++ b/deps/cloudxr/webxr_client/AGENTS.md @@ -21,3 +21,7 @@ startup notices and status cleanup until a stream connects successfully. Selecting Custom preserves current profile-driven settings; named profiles apply their defaults. + +Connect readiness must remain blocked during emulator loading and capability checks, +even when form validation or XR-store notifications update the button. Cover startup +with a delayed emulator bundle and click as soon as Connect becomes available. diff --git a/deps/cloudxr/webxr_client/src/App.tsx b/deps/cloudxr/webxr_client/src/App.tsx index 06addeabdd..9de23a1f19 100644 --- a/deps/cloudxr/webxr_client/src/App.tsx +++ b/deps/cloudxr/webxr_client/src/App.tsx @@ -286,6 +286,10 @@ function AppContent() { } }, [cloudXR2DUI, iwerLoaded, capabilitiesValid]); + useEffect(() => { + cloudXR2DUI?.setCapabilitiesReady(capabilitiesValid); + }, [cloudXR2DUI, capabilitiesValid]); + // Check capabilities once CloudXR2DUI is ready and IWER is loaded useEffect(() => { const checkCapabilitiesOnce = async () => { diff --git a/deps/cloudxr/webxr_client/src/CloudXR2DUI.test.ts b/deps/cloudxr/webxr_client/src/CloudXR2DUI.test.ts index 14344db20b..2312fc69e2 100644 --- a/deps/cloudxr/webxr_client/src/CloudXR2DUI.test.ts +++ b/deps/cloudxr/webxr_client/src/CloudXR2DUI.test.ts @@ -56,3 +56,61 @@ describe('device profile refresh settings', () => { expect(ui.getConfiguration().autoRefreshMode).toBe(mode); }); }); + +describe('Connect capability readiness', () => { + let ui: CloudXR2DUI; + let button: HTMLButtonElement; + + beforeEach(() => { + document.documentElement.innerHTML = readFileSync(join(__dirname, 'index.html'), 'utf8'); + ui = new CloudXR2DUI(); + ui.initialize(); + button = document.getElementById('startButton') as HTMLButtonElement; + }); + + afterEach(() => { + ui.cleanup(); + localStorage.clear(); + jest.restoreAllMocks(); + }); + + it('blocks form updates, session notifications and clicks until capabilities pass', async () => { + const onConnect = jest.fn().mockResolvedValue(undefined); + const onError = jest.fn(); + ui.setupConnectButtonHandler(onConnect, onError); + expect(button.disabled).toBe(true); + + ui.setStartButtonState(false, 'CONNECT'); + const width = document.getElementById('perEyeWidth') as HTMLInputElement; + width.dispatchEvent(new Event('blur', { bubbles: true })); + expect(button.disabled).toBe(true); + button.dispatchEvent(new MouseEvent('click')); + expect(onConnect).not.toHaveBeenCalled(); + + ui.setCapabilitiesReady(true); + expect(button.disabled).toBe(false); + button.click(); + await Promise.resolve(); + expect(onConnect).toHaveBeenCalledTimes(1); + expect(onError).not.toHaveBeenCalled(); + }); + + it('keeps failed capability checks disabled after returning to idle', () => { + ui.setCapabilitiesReady(true); + ui.setCapabilitiesReady(false); + ui.setStartButtonState(false, 'CONNECT'); + ui.updateConnectButtonState(); + expect(button.disabled).toBe(true); + }); + + it('preserves resolution validation after capabilities pass', () => { + const width = document.getElementById('perEyeWidth') as HTMLInputElement; + width.value = '1'; + width.dispatchEvent(new Event('blur', { bubbles: true })); + ui.setCapabilitiesReady(true); + expect(button.disabled).toBe(true); + width.value = '2048'; + width.dispatchEvent(new Event('blur', { bubbles: true })); + expect(button.disabled).toBe(false); + }); +}); diff --git a/deps/cloudxr/webxr_client/src/CloudXR2DUI.tsx b/deps/cloudxr/webxr_client/src/CloudXR2DUI.tsx index fadaffbd27..9be5d5e74c 100644 --- a/deps/cloudxr/webxr_client/src/CloudXR2DUI.tsx +++ b/deps/cloudxr/webxr_client/src/CloudXR2DUI.tsx @@ -97,6 +97,8 @@ export const COUNTDOWN_STORAGE_KEY = 'cxr.react.countdownSeconds'; export class CloudXR2DUI { /** Button to initiate XR streaming session */ private startButton!: HTMLButtonElement; + // Form edits and XR-store notifications must not bypass startup capability checks. + private capabilitiesReady = false; /** Input field for the CloudXR server IP address */ private serverIpInput!: HTMLInputElement; /** Button to clear the prefilled server IP (re-enables the browser autocomplete dropdown) */ @@ -1011,7 +1013,13 @@ export class CloudXR2DUI { this.updateConnectButtonState(); } - /** Disable Connect button and show validation error when resolution invalid; enable when valid. */ + public setCapabilitiesReady(ready: boolean): void { + this.capabilitiesReady = ready; + if (!ready && this.startButton) this.startButton.disabled = true; + this.updateConnectButtonState(); + } + + /** Update Connect availability from capability readiness and form validation. */ public updateConnectButtonState(): void { const { w, h } = getResolutionFromInputs(this.perEyeWidthInput, this.perEyeHeightInput); const { reprojectionGridCols, reprojectionGridRows } = getGridFromInputs( @@ -1034,8 +1042,8 @@ export class CloudXR2DUI { this.validationMessageBox.className = 'validation-message-box'; } // Only update button when idle (don't override "CONNECT (starting...)" or "CONNECT (XR session active)") - if (this.startButton && this.startButton.innerHTML === 'CONNECT') { - const shouldEnable = !resolutionError && !gridError; + if (this.startButton && this.startButton.textContent?.trim() === 'CONNECT') { + const shouldEnable = this.capabilitiesReady && !resolutionError && !gridError; this.setStartButtonState(!shouldEnable, 'CONNECT'); } } @@ -1300,7 +1308,7 @@ export class CloudXR2DUI { */ public setStartButtonState(disabled: boolean, text: string): void { if (this.startButton) { - this.startButton.disabled = disabled; + this.startButton.disabled = disabled || !this.capabilitiesReady; this.startButton.innerHTML = text; } } diff --git a/deps/cloudxr/webxr_client/tests/playwright/AppMockTest.spec.js b/deps/cloudxr/webxr_client/tests/playwright/AppMockTest.spec.js index 560be7cf69..1b173ce72d 100644 --- a/deps/cloudxr/webxr_client/tests/playwright/AppMockTest.spec.js +++ b/deps/cloudxr/webxr_client/tests/playwright/AppMockTest.spec.js @@ -20,22 +20,6 @@ test('opens the real app against MockCloudXR and connects', async ({ page }) => await page.goto('http://localhost:8082/'); - // startButton can report enabled before IWER's device.installRuntime() actually finishes - // (a real race in App.tsx's capability-check gating, not a test artifact - see LoadIWER.ts), - // so clicking on "IWER loaded as fallback." (the first script's onload) is unreliable: it can - // land before navigator.xr is truly usable and permanently wedge the UI with no retry path. - // "IWER DevUI initialized with XR device." isn't a safe wait signal either: it logs BEFORE - // installRuntime() (not after), and it's skipped entirely on the supported no-DevUI path, - // which would falsely fail this test even against a perfectly usable app. Wait for - // LoadIWER.ts's own "IWER runtime installed." instead - unconditional, and only logged once - // installRuntime() has actually succeeded. - await expect - .poll(() => consoleLines.some(l => l.includes('IWER runtime installed.')), { - timeout: 15000, - message: () => `IWER never finished installing; console so far:\n${consoleLines.join('\n')}`, - }) - .toBe(true); - await page.click('#startButton', { timeout: 15000 }); // CloudXRComponent itself logs this unconditionally in onStreamStarted, before calling diff --git a/deps/cloudxr/webxr_client/tests/playwright/ClientUIStatesTest.spec.js b/deps/cloudxr/webxr_client/tests/playwright/ClientUIStatesTest.spec.js index 2c93bdce01..ae9c7db4c4 100644 --- a/deps/cloudxr/webxr_client/tests/playwright/ClientUIStatesTest.spec.js +++ b/deps/cloudxr/webxr_client/tests/playwright/ClientUIStatesTest.spec.js @@ -75,29 +75,21 @@ test.describe('client UI states', () => { }) => { test.setTimeout(30000); - // Block the IWER CDN script the real page fetches (helpers/LoadIWER.ts) to force the - // script.onerror path - the same failure mode as a headset browser that launched but never - // finished loading the client page (here: never finished loading its WebXR emulation dep). - await page.route('**/iwer*.min.js', route => route.abort()); + await page.route(/(?:emulator|iwer.*index_js).*\.js/, route => route.abort()); const consoleLines = []; page.on('console', msg => consoleLines.push(msg.text())); await page.goto('http://localhost:8082/'); - await waitForConsoleText(consoleLines, 'Failed to load IWER.'); - - // App.tsx:263-269 reacts to loadIWERIfNeeded() reporting !supportsImmersive by calling - // showError(), which reliably reaches #errorMessageText (this is the "Detected at launch" - // case from the reliability doc). #startButton's disabled state is deliberately NOT asserted - // here: it races an unrelated bug in updateConnectButtonState() (CloudXR2DUI.tsx:961), which - // unconditionally re-enables the button from resolution/grid validity alone whenever its - // text is exactly 'CONNECT', with no awareness of capabilities/IWER state - so the button can - // end up enabled even after a capability failure that IS otherwise correctly surfaced. + await waitForConsoleText(consoleLines, 'IWER runtime install failed:'); + await expect(page.locator('#errorMessageText')).toHaveText('Immersive mode not supported'); // Text content alone doesn't prove the operator can see it - showStatus() can set // #errorMessageText without the box being visible if it regresses. toBeVisible() respects // the actual .show CSS rule (display: none by default). await expect(page.locator('#errorMessageBox')).toBeVisible(); + await page.locator('#serverIpInput').fill('localhost'); + await expect(page.locator('#startButton')).toBeDisabled(); }); test('passthrough-only: a session that enters but never streams is now detected via streamAttachTimeoutMs', async ({ @@ -127,10 +119,6 @@ test.describe('client UI states', () => { // changes what this test is actually exercising and can outlast a 15s console-wait timeout. // This test wants the no-retry give-up path specifically. await page.goto('http://localhost:8082/?streamAttachTimeoutMs=500&reconnectEnabled=false'); - // See AppMockTest.spec.js: "IWER DevUI initialized with XR device." logs before - // installRuntime() and is skipped on the supported no-DevUI path - "IWER runtime installed." - // is the reliable, unconditional signal that navigator.xr is actually usable. - await waitForConsoleText(consoleLines, 'IWER runtime installed.'); await page.click('#startButton', { timeout: 15000 }); // The session enters immersive-ar and MockCloudXR.connect() is called (proving this isn't @@ -155,7 +143,7 @@ test.describe('client UI states', () => { await waitForConsoleText(consoleLines, 'CloudXR stream did not attach within 500ms'); await waitForConsoleText( consoleLines, - 'CloudXR session stopped: Stream did not attach within 500ms' + 'Stream stopped with error: Stream did not attach within 500ms' ); await expectErrorBannerShown( page, diff --git a/deps/cloudxr/webxr_client/tests/playwright/ConnectReadiness.spec.js b/deps/cloudxr/webxr_client/tests/playwright/ConnectReadiness.spec.js new file mode 100644 index 0000000000..93696542cc --- /dev/null +++ b/deps/cloudxr/webxr_client/tests/playwright/ConnectReadiness.spec.js @@ -0,0 +1,49 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +// @ts-check +const { test, expect } = require('@playwright/test'); + +test('Connect stays disabled until the delayed emulator is ready', async ({ page }) => { + let releaseEmulator; + const emulatorGate = new Promise(resolve => { + releaseEmulator = resolve; + }); + let emulatorRequested = false; + const consoleLines = []; + page.on('console', msg => consoleLines.push(msg.text())); + await page.route(/(?:emulator|iwer.*index_js).*\.js/, async route => { + emulatorRequested = true; + await emulatorGate; + await route.continue(); + }); + + try { + await page.goto('http://localhost:8082/', { waitUntil: 'domcontentloaded' }); + await expect.poll(() => emulatorRequested).toBe(true); + const connect = page.locator('#startButton'); + await expect(connect).toBeDisabled(); + await page.locator('#serverIpInput').fill('localhost'); + await expect(connect).toBeDisabled(); + expect(await page.evaluate(() => !!window.xrDevice)).toBe(false); + + // A trusted pointer click must not reach requestSession while startup is pending. + const bounds = await connect.boundingBox(); + if (!bounds) throw new Error('Connect button is not visible'); + await page.mouse.click(bounds.x + bounds.width / 2, bounds.y + bounds.height / 2); + await expect(page.locator('#errorMessageText')).not.toContainText('Failed to start XR session'); + + releaseEmulator(); + await connect.click(); + await expect + .poll(() => consoleLines.some(line => line.includes('CloudXR stream started')), { + timeout: 15000, + }) + .toBe(true); + await expect(page.locator('#errorMessageText')).not.toContainText('No XR hardware found'); + } finally { + releaseEmulator(); + } +}); From 5ccaac87e9429d989861866e6426ce038e43cdfb Mon Sep 17 00:00:00 2001 From: Yanzi Zhu Date: Thu, 29 Oct 2026 13:09:33 -0700 Subject: [PATCH 3/3] fix(webxr): clean up failed emulator startup and strengthen coverage Signed-off-by: Yanzi Zhu --- .../webxr_client/helpers/LoadIWER.test.ts | 42 ++++++++++++++++++- deps/cloudxr/webxr_client/helpers/LoadIWER.ts | 25 ++++++----- deps/cloudxr/webxr_client/src/CloudXR2DUI.tsx | 3 +- .../playwright/ClientUIStatesTest.spec.js | 3 +- .../tests/playwright/ConnectReadiness.spec.js | 23 +++++++++- .../webxr_client/tests/playwright/emulator.js | 9 ++++ 6 files changed, 90 insertions(+), 15 deletions(-) create mode 100644 deps/cloudxr/webxr_client/tests/playwright/emulator.js diff --git a/deps/cloudxr/webxr_client/helpers/LoadIWER.test.ts b/deps/cloudxr/webxr_client/helpers/LoadIWER.test.ts index 67ba4a0486..3df8a58ce2 100644 --- a/deps/cloudxr/webxr_client/helpers/LoadIWER.test.ts +++ b/deps/cloudxr/webxr_client/helpers/LoadIWER.test.ts @@ -27,7 +27,7 @@ describe('loadIWERIfNeeded', () => { let loadIWERIfNeeded: () => Promise; let XRDevice: jest.Mock; let DevUI: jest.Mock; - let device: { installDevUI: jest.Mock; installRuntime: jest.Mock }; + let device: { installDevUI: jest.Mock; installRuntime: jest.Mock; uninstallRuntime: jest.Mock }; beforeEach(() => { originalXRDescriptor = Object.getOwnPropertyDescriptor(navigator, 'xr'); @@ -36,9 +36,19 @@ describe('loadIWERIfNeeded', () => { ({ loadIWERIfNeeded } = require('./LoadIWER')); ({ XRDevice } = jest.requireMock('iwer')); ({ DevUI } = jest.requireMock('@iwer/devui')); + let previousXR: PropertyDescriptor | undefined; + let runtimeInstalled = false; device = { installDevUI: jest.fn(), + uninstallRuntime: jest.fn(() => { + if (!runtimeInstalled) return; + runtimeInstalled = false; + if (previousXR) Object.defineProperty(navigator, 'xr', previousXR); + else delete (navigator as Navigator & { xr?: XRSystem }).xr; + }), installRuntime: jest.fn(() => { + previousXR = Object.getOwnPropertyDescriptor(navigator, 'xr'); + runtimeInstalled = true; Object.defineProperty(navigator, 'xr', { configurable: true, value: { isSessionSupported: jest.fn().mockResolvedValue(true) }, @@ -109,6 +119,8 @@ describe('loadIWERIfNeeded', () => { iwerLoaded: false, }); expect(navigator.xr).toBe(nativeXR); + expect(device.uninstallRuntime).toHaveBeenCalledTimes(1); + expect(device.installDevUI).not.toHaveBeenCalled(); expect(window.xrDevice).toBeUndefined(); await expect(loadIWERIfNeeded()).resolves.toEqual({ @@ -119,6 +131,34 @@ describe('loadIWERIfNeeded', () => { expect(device.installRuntime).toHaveBeenCalledTimes(2); }); + it('rolls back an unsupported runtime before retrying without duplicating DevUI', async () => { + const installRuntime = device.installRuntime.getMockImplementation()!; + device.installRuntime.mockImplementationOnce(() => { + installRuntime(); + Object.defineProperty(navigator, 'xr', { + configurable: true, + value: { isSessionSupported: jest.fn().mockResolvedValue(false) }, + }); + }); + + await expect(loadIWERIfNeeded()).resolves.toEqual({ + supportsImmersive: false, + iwerLoaded: false, + }); + expect(device.uninstallRuntime).toHaveBeenCalledTimes(1); + expect(device.installDevUI).not.toHaveBeenCalled(); + expect(window.xrDevice).toBeUndefined(); + + await expect(loadIWERIfNeeded()).resolves.toEqual({ + supportsImmersive: true, + iwerLoaded: true, + }); + expect(device.installDevUI).toHaveBeenCalledTimes(1); + expect(device.installRuntime).toHaveBeenCalledTimes(2); + expect(device.uninstallRuntime).toHaveBeenCalledTimes(1); + expect(window.xrDevice).toBe(device); + }); + it('retries a failed runtime installation', async () => { device.installRuntime.mockRejectedValueOnce(new Error('transient failure')); await expect(loadIWERIfNeeded()).resolves.toEqual({ diff --git a/deps/cloudxr/webxr_client/helpers/LoadIWER.ts b/deps/cloudxr/webxr_client/helpers/LoadIWER.ts index c7634f3db9..af46b924cf 100644 --- a/deps/cloudxr/webxr_client/helpers/LoadIWER.ts +++ b/deps/cloudxr/webxr_client/helpers/LoadIWER.ts @@ -67,18 +67,23 @@ async function loadIWER(): Promise { import(/* webpackMode: "lazy" */ '@iwer/devui'), ]); const device = new XRDevice(metaQuest3); - device.installDevUI(DevUI); - // The native XRSystem has already reported no immersive support. - await device.installRuntime({ forceInstall: true }); - // Installation can return without replacing navigator.xr. Verify support - // before reporting readiness to enable Connect. - const [vr, ar] = await Promise.all([ - navigator.xr?.isSessionSupported('immersive-vr'), - navigator.xr?.isSessionSupported('immersive-ar'), - ]); - supportsImmersive = Boolean(vr || ar); + try { + // The native XRSystem has already reported no immersive support. + await device.installRuntime({ forceInstall: true }); + // Installation can return without replacing navigator.xr. Verify support + // before reporting readiness to enable Connect. + const [vr, ar] = await Promise.all([ + navigator.xr?.isSessionSupported('immersive-vr'), + navigator.xr?.isSessionSupported('immersive-ar'), + ]); + supportsImmersive = Boolean(vr || ar); + } finally { + // Restore globals before a retry; create DevUI only for a usable runtime. + if (!supportsImmersive) device.uninstallRuntime(); + } if (supportsImmersive) { + device.installDevUI(DevUI); window.xrDevice = device; iwerLoaded = true; console.info('IWER runtime installed.'); diff --git a/deps/cloudxr/webxr_client/src/CloudXR2DUI.tsx b/deps/cloudxr/webxr_client/src/CloudXR2DUI.tsx index 9be5d5e74c..95f46ce225 100644 --- a/deps/cloudxr/webxr_client/src/CloudXR2DUI.tsx +++ b/deps/cloudxr/webxr_client/src/CloudXR2DUI.tsx @@ -97,7 +97,7 @@ export const COUNTDOWN_STORAGE_KEY = 'cxr.react.countdownSeconds'; export class CloudXR2DUI { /** Button to initiate XR streaming session */ private startButton!: HTMLButtonElement; - // Form edits and XR-store notifications must not bypass startup capability checks. + /** Form edits and XR-store notifications must not bypass startup capability checks. */ private capabilitiesReady = false; /** Input field for the CloudXR server IP address */ private serverIpInput!: HTMLInputElement; @@ -1013,6 +1013,7 @@ export class CloudXR2DUI { this.updateConnectButtonState(); } + /** Gate Connect on completed startup checks, preserving form validation. */ public setCapabilitiesReady(ready: boolean): void { this.capabilitiesReady = ready; if (!ready && this.startButton) this.startButton.disabled = true; diff --git a/deps/cloudxr/webxr_client/tests/playwright/ClientUIStatesTest.spec.js b/deps/cloudxr/webxr_client/tests/playwright/ClientUIStatesTest.spec.js index ae9c7db4c4..018c3f72f1 100644 --- a/deps/cloudxr/webxr_client/tests/playwright/ClientUIStatesTest.spec.js +++ b/deps/cloudxr/webxr_client/tests/playwright/ClientUIStatesTest.spec.js @@ -5,6 +5,7 @@ // @ts-check const { test, expect } = require('@playwright/test'); +const { EMULATOR_BUNDLE_PATTERN } = require('./emulator'); /** * Client-side-only coverage for the real App.tsx (via webpack.app-mock.js, :8082) of two @@ -75,7 +76,7 @@ test.describe('client UI states', () => { }) => { test.setTimeout(30000); - await page.route(/(?:emulator|iwer.*index_js).*\.js/, route => route.abort()); + await page.route(EMULATOR_BUNDLE_PATTERN, route => route.abort()); const consoleLines = []; page.on('console', msg => consoleLines.push(msg.text())); diff --git a/deps/cloudxr/webxr_client/tests/playwright/ConnectReadiness.spec.js b/deps/cloudxr/webxr_client/tests/playwright/ConnectReadiness.spec.js index 93696542cc..adf2054d0d 100644 --- a/deps/cloudxr/webxr_client/tests/playwright/ConnectReadiness.spec.js +++ b/deps/cloudxr/webxr_client/tests/playwright/ConnectReadiness.spec.js @@ -5,6 +5,7 @@ // @ts-check const { test, expect } = require('@playwright/test'); +const { EMULATOR_BUNDLE_PATTERN } = require('./emulator'); test('Connect stays disabled until the delayed emulator is ready', async ({ page }) => { let releaseEmulator; @@ -14,12 +15,26 @@ test('Connect stays disabled until the delayed emulator is ready', async ({ page let emulatorRequested = false; const consoleLines = []; page.on('console', msg => consoleLines.push(msg.text())); - await page.route(/(?:emulator|iwer.*index_js).*\.js/, async route => { + await page.route(EMULATOR_BUNDLE_PATTERN, async route => { emulatorRequested = true; await emulatorGate; await route.continue(); }); + await page.addInitScript(() => { + window.__earlyXRSessionRequests = 0; + Object.defineProperty(navigator, 'xr', { + configurable: true, + value: Object.assign(new EventTarget(), { + isSessionSupported: async () => false, + requestSession: () => { + window.__earlyXRSessionRequests++; + return Promise.reject(new Error('XR requested before emulator readiness')); + }, + }), + }); + }); + try { await page.goto('http://localhost:8082/', { waitUntil: 'domcontentloaded' }); await expect.poll(() => emulatorRequested).toBe(true); @@ -33,7 +48,11 @@ test('Connect stays disabled until the delayed emulator is ready', async ({ page const bounds = await connect.boundingBox(); if (!bounds) throw new Error('Connect button is not visible'); await page.mouse.click(bounds.x + bounds.width / 2, bounds.y + bounds.height / 2); - await expect(page.locator('#errorMessageText')).not.toContainText('Failed to start XR session'); + // Let click-triggered async work run while the emulator is still blocked. + await page.evaluate( + () => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve))) + ); + expect(await page.evaluate(() => window.__earlyXRSessionRequests)).toBe(0); releaseEmulator(); await connect.click(); diff --git a/deps/cloudxr/webxr_client/tests/playwright/emulator.js b/deps/cloudxr/webxr_client/tests/playwright/emulator.js new file mode 100644 index 0000000000..7075dcaaa3 --- /dev/null +++ b/deps/cloudxr/webxr_client/tests/playwright/emulator.js @@ -0,0 +1,9 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +// Production uses the emulator chunk; development names the IWER module chunk. +const EMULATOR_BUNDLE_PATTERN = /(?:emulator|iwer.*index_js).*\.js/; + +module.exports = { EMULATOR_BUNDLE_PATTERN };