Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions deps/cloudxr/webxr_client/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
72 changes: 70 additions & 2 deletions deps/cloudxr/webxr_client/helpers/LoadIWER.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ describe('loadIWERIfNeeded', () => {
let loadIWERIfNeeded: () => Promise<IWERLoadResult>;
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');
Expand All @@ -36,7 +36,25 @@ describe('loadIWERIfNeeded', () => {
({ loadIWERIfNeeded } = require('./LoadIWER'));
({ XRDevice } = jest.requireMock('iwer'));
({ DevUI } = jest.requireMock('@iwer/devui'));
device = { installDevUI: jest.fn(), installRuntime: jest.fn() };
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) },
});
}),
};
XRDevice.mockReturnValue(device);
});

Expand Down Expand Up @@ -91,6 +109,56 @@ 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(device.uninstallRuntime).toHaveBeenCalledTimes(1);
expect(device.installDevUI).not.toHaveBeenCalled();
expect(window.xrDevice).toBeUndefined();

await expect(loadIWERIfNeeded()).resolves.toEqual({
supportsImmersive: true,
iwerLoaded: true,
});
expect(window.xrDevice).toBe(device);
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({
Expand Down
29 changes: 22 additions & 7 deletions deps/cloudxr/webxr_client/helpers/LoadIWER.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,14 +67,29 @@ async function loadIWER(): Promise<IWERLoadResult> {
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 });
window.xrDevice = device;
supportsImmersive = true;
iwerLoaded = true;
console.info('IWER runtime installed.');
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.');
} else {
console.warn('IWER runtime installed without immersive session support.');
}
} catch (error) {
console.warn('IWER runtime install failed:', error);
}
Expand Down
4 changes: 4 additions & 0 deletions deps/cloudxr/webxr_client/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down
58 changes: 58 additions & 0 deletions deps/cloudxr/webxr_client/src/CloudXR2DUI.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
17 changes: 13 additions & 4 deletions deps/cloudxr/webxr_client/src/CloudXR2DUI.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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) */
Expand Down Expand Up @@ -1011,7 +1013,14 @@ export class CloudXR2DUI {
this.updateConnectButtonState();
}

/** Disable Connect button and show validation error when resolution invalid; enable when valid. */
/** 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;
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(
Expand All @@ -1034,8 +1043,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');
}
}
Expand Down Expand Up @@ -1300,7 +1309,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;
}
}
Expand Down
16 changes: 0 additions & 16 deletions deps/cloudxr/webxr_client/tests/playwright/AppMockTest.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -75,29 +76,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_BUNDLE_PATTERN, 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 ({
Expand Down Expand Up @@ -127,10 +120,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
Expand All @@ -155,7 +144,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,
Expand Down
Loading
Loading