fix(mobile): stabilize offline sensing fallback

This commit is contained in:
ruv committed 2026-08-24 11:55:38 -04:00
1 parent 7036282ccf
commit a11d9a4aa0
2 files changed
+90 -9

No files matched your search

@@ -111,6 +111,73 @@ describe('WsService', () => {
});
});
describe('connection fallback', () => {
it('uses simulation when an active server connection closes normally', () => {
const OrigWebSocket = globalThis.WebSocket;
let socket: any;
class MockWebSocket {
static OPEN = 1;
static CONNECTING = 0;
readyState = 0;
onopen: (() => void) | null = null;
onclose: ((event: { code: number }) => void) | null = null;
onerror: (() => void) | null = null;
onmessage: (() => void) | null = null;
close() {}
constructor() {
socket = this;
}
}
globalThis.WebSocket = MockWebSocket as any;
try {
const ws = createWsService();
ws.connect('http://localhost:3000');
socket.onclose?.({ code: 1000 });
expect(ws.getStatus()).toBe('simulated');
ws.disconnect();
} finally {
globalThis.WebSocket = OrigWebSocket;
}
});
it('preserves retry progress instead of restarting the first attempt', () => {
const OrigWebSocket = globalThis.WebSocket;
const sockets: any[] = [];
class MockWebSocket {
static OPEN = 1;
static CONNECTING = 0;
readyState = 0;
onopen: (() => void) | null = null;
onclose: ((event: { code: number }) => void) | null = null;
onerror: (() => void) | null = null;
onmessage: (() => void) | null = null;
close() {}
constructor() {
sockets.push(this);
}
}
globalThis.WebSocket = MockWebSocket as any;
try {
const ws = createWsService();
ws.connect('http://localhost:3000');
sockets[0].onclose?.({ code: 1006 });
jest.runOnlyPendingTimers();
sockets[1].onclose?.({ code: 1006 });
expect(ws.getStatus()).toBe('simulated');
expect(sockets).toHaveLength(2);
ws.disconnect();
} finally {
globalThis.WebSocket = OrigWebSocket;
}
});
});
describe('subscribe and unsubscribe', () => {
it('adds a listener and returns an unsubscribe function', () => {
const ws = createWsService();
+23 -9
View File
@@ -17,9 +17,16 @@ class WsService {
private status: ConnectionStatus = 'disconnected';
connect(url: string): void {
const wasActive = this.active;
const targetChanged = url !== this.targetUrl;
this.targetUrl = url;
this.active = true;
this.reconnectAttempt = 0;
// A scheduled reconnect must preserve its attempt count. Resetting it on
// every call traps an unavailable installation in an infinite first retry.
if (!wasActive || targetChanged) {
this.reconnectAttempt = 0;
}
if (!url) {
this.handleStatusChange('simulated');
@@ -31,10 +38,18 @@ class WsService {
return;
}
this.handleStatusChange('connecting');
this.openConnection();
}
private openConnection(): void {
// When simulation is already providing frames, keep that honest status in
// the UI while the real endpoint is probed in the background.
if (!this.simulationTimer) {
this.handleStatusChange('connecting');
}
try {
const endpoint = this.buildWsUrl(url);
const endpoint = this.buildWsUrl(this.targetUrl);
const socket = new WebSocket(endpoint);
this.ws = socket;
@@ -64,10 +79,8 @@ class WsService {
this.handleStatusChange('disconnected');
return;
}
if (evt.code === 1000) {
this.handleStatusChange('disconnected');
return;
}
// A server is allowed to close with 1000. It is only an intentional
// client disconnect when `active` is false (handled above).
this.scheduleReconnect();
};
} catch {
@@ -125,12 +138,13 @@ class WsService {
const delay = RECONNECT_DELAYS[Math.min(this.reconnectAttempt, RECONNECT_DELAYS.length - 1)];
this.reconnectAttempt += 1;
this.startSimulation();
this.handleStatusChange('simulated');
this.clearReconnectTimer();
this.reconnectTimer = setTimeout(() => {
this.reconnectTimer = null;
this.connect(this.targetUrl);
this.openConnection();
}, delay);
this.startSimulation();
}
private startSimulation(): void {