From 4f2a6d6eec434a3b1d3b01732d5de42f8bbab7b0 Mon Sep 17 00:00:00 2001 From: Christian Holbrook Date: Wed, 13 May 2026 13:29:31 -0600 Subject: [PATCH 1/2] Add new class for holding all the globabl storage for device discovery related features --- .../DeviceStorageManager.spec.ts | 438 ++++++++++++++++++ src/deviceDiscovery/DeviceStorageManager.ts | 303 ++++++++++++ 2 files changed, 741 insertions(+) create mode 100644 src/deviceDiscovery/DeviceStorageManager.spec.ts create mode 100644 src/deviceDiscovery/DeviceStorageManager.ts diff --git a/src/deviceDiscovery/DeviceStorageManager.spec.ts b/src/deviceDiscovery/DeviceStorageManager.spec.ts new file mode 100644 index 00000000..bb64bbb2 --- /dev/null +++ b/src/deviceDiscovery/DeviceStorageManager.spec.ts @@ -0,0 +1,438 @@ +import { expect } from 'chai'; +let Module = require('module'); +import { vscode } from '../mockVscode.spec'; + +//override the "require" call to mock certain items (specifically 'vscode') +const { require: oldRequire } = Module.prototype; +Module.prototype.require = function hijacked(file) { + if (file === 'vscode') { + return vscode; + } else { + return oldRequire.apply(this, arguments); + } +}; + +import { DeviceStorageManager } from './DeviceStorageManager'; + +describe('DeviceStorageManager', () => { + let manager: DeviceStorageManager; + let mockContext: any; + let storage: Record; + const testNetwork = 'test-network-hash'; + + beforeEach(() => { + storage = {}; + mockContext = { + globalState: { + get: (key: string) => storage[key], + update: (key: string, value: any) => { + storage[key] = value; + } + } + }; + manager = new DeviceStorageManager(mockContext); + }); + + describe('lastSeenDevices', () => { + it('returns empty array when no devices stored', () => { + expect(manager.getLastSeenDevices(testNetwork)).to.deep.equal([]); + }); + + it('adds a new device', () => { + manager.addLastSeenDevice(testNetwork, 'device-123'); + expect(manager.getLastSeenDevices(testNetwork)).to.deep.equal(['device-123']); + }); + + it('does not add duplicate devices', () => { + manager.addLastSeenDevice(testNetwork, 'device-123'); + manager.addLastSeenDevice(testNetwork, 'device-123'); + expect(manager.getLastSeenDevices(testNetwork)).to.deep.equal(['device-123']); + }); + + it('removes a device', () => { + manager.addLastSeenDevice(testNetwork, 'device-123'); + manager.addLastSeenDevice(testNetwork, 'device-456'); + manager.removeLastSeenDevice(testNetwork, 'device-123'); + expect(manager.getLastSeenDevices(testNetwork)).to.deep.equal(['device-456']); + }); + + it('keeps devices separate by network', () => { + const network1 = 'network-1'; + const network2 = 'network-2'; + manager.addLastSeenDevice(network1, 'device-123'); + manager.addLastSeenDevice(network2, 'device-456'); + expect(manager.getLastSeenDevices(network1)).to.deep.equal(['device-123']); + expect(manager.getLastSeenDevices(network2)).to.deep.equal(['device-456']); + }); + }); + + describe('deviceCache', () => { + const testDevice = { + serialNumber: 'device-123', + deviceInfo: { + 'device-id': 'device-123', + 'default-device-name': 'Roku Express' + }, + createdAt: Date.now() + }; + + it('returns undefined when device not cached', () => { + expect(manager.getCachedDevice('device-123')).to.be.undefined; + }); + + it('caches and retrieves a device', () => { + manager.setCachedDevice('device-123', testDevice); + expect(manager.getCachedDevice('device-123')).to.deep.equal(testDevice); + }); + + it('removes a cached device', () => { + manager.setCachedDevice('device-123', testDevice); + manager.removeCachedDevice('device-123'); + expect(manager.getCachedDevice('device-123')).to.be.undefined; + }); + + it('keeps devices separate by serial number', () => { + const device1 = { ...testDevice, serialNumber: 'device-123' }; + const device2 = { ...testDevice, serialNumber: 'device-456' }; + manager.setCachedDevice('device-123', device1); + manager.setCachedDevice('device-456', device2); + expect(manager.getCachedDevice('device-123')).to.deep.equal(device1); + expect(manager.getCachedDevice('device-456')).to.deep.equal(device2); + }); + }); + + describe('serialNumberByIpForNetwork', () => { + const network1 = 'network-hash-1'; + const network2 = 'network-hash-2'; + const serial1 = 'SERIAL001'; + const serial2 = 'SERIAL002'; + + it('stores a new IP→serial mapping', () => { + manager.setSerialNumberForIp(network1, '192.168.1.10', serial1); + expect(manager.getSerialNumberForIp('192.168.1.10', network1)).to.equal(serial1); + }); + + it('returns undefined for unknown IP', () => { + expect(manager.getSerialNumberForIp('10.0.0.1', network1)).to.be.undefined; + }); + + it('removes old IP entry when same serial is seen at a new IP on the same network', () => { + manager.setSerialNumberForIp(network1, '192.168.1.10', serial1); + manager.setSerialNumberForIp(network1, '192.168.1.20', serial1); + + // New IP should resolve correctly + expect(manager.getSerialNumberForIp('192.168.1.20', network1)).to.equal(serial1); + // Old IP should no longer exist in the cache + expect(manager.getSerialNumberForIp('192.168.1.10', network1)).to.be.undefined; + }); + + it('purges all stale entries when a serial accumulates multiple old IPs on the same network', () => { + // Simulate a Roku that changed IPs several times + manager.setSerialNumberForIp(network1, '192.168.1.10', serial1); + manager.setSerialNumberForIp(network1, '192.168.1.11', serial1); + manager.setSerialNumberForIp(network1, '192.168.1.12', serial1); + // Now it moves to a new IP + manager.setSerialNumberForIp(network1, '192.168.1.99', serial1); + + // Only the latest IP survives + expect(manager.getSerialNumberForIp('192.168.1.99', network1)).to.equal(serial1); + expect(manager.getSerialNumberForIp('192.168.1.10', network1)).to.be.undefined; + expect(manager.getSerialNumberForIp('192.168.1.11', network1)).to.be.undefined; + expect(manager.getSerialNumberForIp('192.168.1.12', network1)).to.be.undefined; + }); + + it('does not affect entries for a different serial on the same network', () => { + manager.setSerialNumberForIp(network1, '192.168.1.10', serial1); + manager.setSerialNumberForIp(network1, '192.168.1.20', serial2); + // Move serial1 to a new IP + manager.setSerialNumberForIp(network1, '192.168.1.30', serial1); + + expect(manager.getSerialNumberForIp('192.168.1.30', network1)).to.equal(serial1); + // serial2 entry is untouched + expect(manager.getSerialNumberForIp('192.168.1.20', network1)).to.equal(serial2); + }); + + it('does not remove entries for the same serial on a different network', () => { + manager.setSerialNumberForIp(network1, '192.168.1.10', serial1); + manager.setSerialNumberForIp(network2, '10.0.0.5', serial1); + // Update serial1 on network1 only + manager.setSerialNumberForIp(network1, '192.168.1.50', serial1); + + expect(manager.getSerialNumberForIp('192.168.1.50', network1)).to.equal(serial1); + expect(manager.getSerialNumberForIp('192.168.1.10', network1)).to.be.undefined; + // network2 entry should be untouched + expect(manager.getSerialNumberForIp('10.0.0.5', network2)).to.equal(serial1); + }); + + it('getIpForSerial returns the most recent IP after deduplication', () => { + manager.setSerialNumberForIp(network1, '192.168.1.10', serial1); + manager.setSerialNumberForIp(network1, '192.168.1.20', serial1); + + expect(manager.getIpForSerial(serial1, network1)).to.equal('192.168.1.20'); + }); + + it('overwrites mapping when the same IP is re-used by the same serial', () => { + manager.setSerialNumberForIp(network1, '192.168.1.10', serial1); + manager.setSerialNumberForIp(network1, '192.168.1.10', serial1); + + expect(manager.getSerialNumberForIp('192.168.1.10', network1)).to.equal(serial1); + }); + }); + + describe('clearExpiredDevices', () => { + const testDevice = { + serialNumber: 'device-123', + deviceInfo: { + 'device-id': 'device-123', + 'default-device-name': 'Roku Express' + }, + createdAt: Date.now() + }; + + beforeEach(() => { + // Use a short expiration for testing (1 second) + (manager as any).LAST_SEEN_NETWORK_EXPIRATION = 1_000; + }); + + it('keeps devices that are not expired', () => { + manager.setCachedDevice('device-123', { ...testDevice, createdAt: Date.now() }); + manager.clearExpiredDevices(); + expect(manager.getCachedDevice('device-123')).to.not.be.undefined; + }); + + it('removes devices that are expired', () => { + manager.setCachedDevice('device-123', { ...testDevice, createdAt: Date.now() - 2_000 }); + manager.clearExpiredDevices(); + expect(manager.getCachedDevice('device-123')).to.be.undefined; + }); + + it('removes only expired devices and keeps fresh ones', () => { + manager.setCachedDevice('old-device', { ...testDevice, serialNumber: 'old-device', createdAt: Date.now() - 2_000 }); + manager.setCachedDevice('new-device', { ...testDevice, serialNumber: 'new-device', createdAt: Date.now() }); + manager.clearExpiredDevices(); + expect(manager.getCachedDevice('old-device')).to.be.undefined; + expect(manager.getCachedDevice('new-device')).to.not.be.undefined; + }); + + it('handles empty cache', () => { + manager.clearExpiredDevices(); + expect(manager.getCachedDevice('device-123')).to.be.undefined; + }); + }); + + describe('setSerialNumberForIp', () => { + it('stores IP to serial mapping', () => { + manager.setSerialNumberForIp(testNetwork, '192.168.1.100', 'serial-123'); + expect(manager.getSerialNumberForIp('192.168.1.100', testNetwork)).to.equal('serial-123'); + }); + + it('removes old IP entry when serial moves to new IP', () => { + manager.setSerialNumberForIp(testNetwork, '192.168.1.100', 'serial-123'); + manager.setSerialNumberForIp(testNetwork, '192.168.1.200', 'serial-123'); + + // Old IP should no longer have the serial + expect(manager.getSerialNumberForIp('192.168.1.100', testNetwork)).to.be.undefined; + // New IP should have the serial + expect(manager.getSerialNumberForIp('192.168.1.200', testNetwork)).to.equal('serial-123'); + }); + + it('allows same serial at same IP (update timestamp)', () => { + manager.setSerialNumberForIp(testNetwork, '192.168.1.100', 'serial-123'); + manager.setSerialNumberForIp(testNetwork, '192.168.1.100', 'serial-123'); + + // IP should still have the serial + expect(manager.getSerialNumberForIp('192.168.1.100', testNetwork)).to.equal('serial-123'); + }); + + it('allows different serials at different IPs', () => { + manager.setSerialNumberForIp(testNetwork, '192.168.1.100', 'serial-123'); + manager.setSerialNumberForIp(testNetwork, '192.168.1.200', 'serial-456'); + + expect(manager.getSerialNumberForIp('192.168.1.100', testNetwork)).to.equal('serial-123'); + expect(manager.getSerialNumberForIp('192.168.1.200', testNetwork)).to.equal('serial-456'); + }); + + it('replaces serial at same IP', () => { + manager.setSerialNumberForIp(testNetwork, '192.168.1.100', 'serial-123'); + manager.setSerialNumberForIp(testNetwork, '192.168.1.100', 'serial-456'); + + expect(manager.getSerialNumberForIp('192.168.1.100', testNetwork)).to.equal('serial-456'); + }); + }); + + describe('getIpForSerial', () => { + const networkA = 'network-hash-aaaaaa'; + const networkB = 'network-hash-bbbbbb'; + const networkC = 'network-hash-cccccc'; + + it('returns undefined when no mappings exist', () => { + expect(manager.getIpForSerial('ABC123')).to.be.undefined; + }); + + it('returns the IP when found in current network', () => { + manager.setSerialNumberForIp(networkA, '10.0.1.100', 'ABC123'); + expect(manager.getIpForSerial('ABC123', networkA)).to.equal('10.0.1.100'); + }); + + it('returns undefined when serial is not in current network and no other networks', () => { + manager.setSerialNumberForIp(networkA, '10.0.1.100', 'ABC123'); + expect(manager.getIpForSerial('OTHER999', networkA)).to.be.undefined; + }); + + it('falls back to another network when serial not found in current network', () => { + manager.setSerialNumberForIp(networkB, '10.0.2.200', 'ABC123'); + // networkA has no entry for ABC123 + expect(manager.getIpForSerial('ABC123', networkA)).to.equal('10.0.2.200'); + }); + + it('returns the most recently updated IP across multiple networks', () => { + const now = Date.now(); + // Manually inject entries with specific timestamps to control ordering + const key = 'serialNumberByIpForNetwork'; + storage[key] = { + [networkA]: { + '10.0.1.100': { serialNumber: 'ABC123', timestamp: now - 5_000 } + }, + [networkB]: { + '10.0.2.200': { serialNumber: 'ABC123', timestamp: now - 1_000 } + }, + [networkC]: { + '10.0.3.300': { serialNumber: 'ABC123', timestamp: now - 3_000 } + } + }; + + // Should pick the most recently updated IP (from networkB) + expect(manager.getIpForSerial('ABC123')).to.equal('10.0.2.200'); + }); + + it('returns current network IP even if another network has a more recent timestamp', () => { + const now = Date.now(); + const key = 'serialNumberByIpForNetwork'; + storage[key] = { + [networkA]: { + '10.0.1.100': { serialNumber: 'ABC123', timestamp: now - 10_000 } + }, + [networkB]: { + '10.0.2.200': { serialNumber: 'ABC123', timestamp: now - 1_000 } + } + }; + + // Current network A has an older timestamp, but it should be preferred + expect(manager.getIpForSerial('ABC123', networkA)).to.equal('10.0.1.100'); + }); + + it('returns the most recently updated IP when same serial appears in multiple networks with no current network', () => { + const now = Date.now(); + const key = 'serialNumberByIpForNetwork'; + storage[key] = { + [networkA]: { + '10.0.1.100': { serialNumber: 'ABC123', timestamp: now - 9_000 }, + '10.0.1.101': { serialNumber: 'XYZ789', timestamp: now - 2_000 } + }, + [networkB]: { + '10.0.2.200': { serialNumber: 'ABC123', timestamp: now - 500 } + } + }; + + // No current network specified - should return most recent across all networks + expect(manager.getIpForSerial('ABC123')).to.equal('10.0.2.200'); + }); + + it('does not confuse different serial numbers in the same network', () => { + manager.setSerialNumberForIp(networkA, '10.0.1.100', 'ABC123'); + manager.setSerialNumberForIp(networkA, '10.0.1.101', 'XYZ789'); + + expect(manager.getIpForSerial('ABC123', networkA)).to.equal('10.0.1.100'); + expect(manager.getIpForSerial('XYZ789', networkA)).to.equal('10.0.1.101'); + }); + }); + + describe('getSerialNumberForIp', () => { + const networkA = 'network-hash-aaaaaa'; + const networkB = 'network-hash-bbbbbb'; + const networkC = 'network-hash-cccccc'; + + it('returns undefined when no mappings exist', () => { + expect(manager.getSerialNumberForIp('10.0.1.100')).to.be.undefined; + }); + + it('returns the serial number when found in current network', () => { + manager.setSerialNumberForIp(networkA, '10.0.1.100', 'ABC123'); + expect(manager.getSerialNumberForIp('10.0.1.100', networkA)).to.equal('ABC123'); + }); + + it('returns undefined when IP not found in current network (strict lookup)', () => { + manager.setSerialNumberForIp(networkB, '10.0.2.200', 'XYZ789'); + // networkA has no entry for this IP - strict lookup returns undefined + expect(manager.getSerialNumberForIp('10.0.2.200', networkA)).to.be.undefined; + }); + + it('falls back to another network when no network specified', () => { + manager.setSerialNumberForIp(networkB, '10.0.2.200', 'XYZ789'); + // No network specified - falls back to search all networks + expect(manager.getSerialNumberForIp('10.0.2.200')).to.equal('XYZ789'); + }); + + it('returns the most recently updated serial across multiple networks', () => { + const now = Date.now(); + const key = 'serialNumberByIpForNetwork'; + storage[key] = { + [networkA]: { + '10.0.1.100': { serialNumber: 'OLD123', timestamp: now - 5_000 } + }, + [networkB]: { + '10.0.1.100': { serialNumber: 'NEW456', timestamp: now - 1_000 } + }, + [networkC]: { + '10.0.1.100': { serialNumber: 'MID789', timestamp: now - 3_000 } + } + }; + + // No current network - should return most recent by timestamp + expect(manager.getSerialNumberForIp('10.0.1.100')).to.equal('NEW456'); + }); + + it('returns current network serial even if another network has a more recent entry for the same IP', () => { + const now = Date.now(); + const key = 'serialNumberByIpForNetwork'; + storage[key] = { + [networkA]: { + '10.0.1.100': { serialNumber: 'LOCAL123', timestamp: now - 10_000 } + }, + [networkB]: { + '10.0.1.100': { serialNumber: 'REMOTE456', timestamp: now - 1_000 } + } + }; + + // Current network A preferred over the more recent networkB entry + expect(manager.getSerialNumberForIp('10.0.1.100', networkA)).to.equal('LOCAL123'); + }); + }); + + describe('heartbeat timestamps', () => { + it('returns undefined when no timestamp stored', () => { + expect(manager.getLastAliveTimestamp('device-123')).to.be.undefined; + }); + + it('stores and retrieves a timestamp', () => { + const now = Date.now(); + manager.setLastAliveTimestamp('device-123', now); + expect(manager.getLastAliveTimestamp('device-123')).to.equal(now); + }); + + it('keeps timestamps separate by key', () => { + const now = Date.now(); + manager.setLastAliveTimestamp('device-123', now); + manager.setLastAliveTimestamp('device-456', now + 1000); + expect(manager.getLastAliveTimestamp('device-123')).to.equal(now); + expect(manager.getLastAliveTimestamp('device-456')).to.equal(now + 1000); + }); + + it('overwrites existing timestamp', () => { + const now = Date.now(); + manager.setLastAliveTimestamp('device-123', now); + manager.setLastAliveTimestamp('device-123', now + 5000); + expect(manager.getLastAliveTimestamp('device-123')).to.equal(now + 5000); + }); + }); +}); diff --git a/src/deviceDiscovery/DeviceStorageManager.ts b/src/deviceDiscovery/DeviceStorageManager.ts new file mode 100644 index 00000000..8657a6e1 --- /dev/null +++ b/src/deviceDiscovery/DeviceStorageManager.ts @@ -0,0 +1,303 @@ +import * as vscode from 'vscode'; + +/** + * Interface for components that need heartbeat tracking (e.g., RokuFinder) + */ +export interface HeartbeatProvider { + getLastAliveTimestamp(key: string): number | undefined; + setLastAliveTimestamp(key: string, timestamp: number): void; +} + +/** + * Manages device-related persistent storage (last seen devices, device cache, IP mappings, heartbeats) + */ +export class DeviceStorageManager implements HeartbeatProvider { + constructor( + private context: vscode.ExtensionContext + ) { } + + private keys = { + lastSeenDevicesByNetwork: 'lastSeenDevicesByNetwork', + deviceCache: 'deviceCache', + serialNumberByIpForNetwork: 'serialNumberByIpForNetwork', + lastAliveTimestamp: 'lastAliveTimestamp' + }; + + private LAST_SEEN_NETWORK_EXPIRATION = 30 * 24 * 60 * 60 * 1_000; // 30 days + + // ==================== Last Seen Devices ==================== + + public getLastSeenDevices(network: string): string[] { + const networks = this.context.globalState.get>(this.keys.lastSeenDevicesByNetwork) || {}; + const entry = networks[network]; + const serialNumbers = entry?.serialNumbers ?? []; + if (serialNumbers.length !== 0) { + networks[network] = { serialNumbers: serialNumbers, lastSeen: Date.now() }; + void this.context.globalState.update(this.keys.lastSeenDevicesByNetwork, this.expireOldLastSeenNetworks(networks)); + } + return serialNumbers; + } + + public setLastSeenDevices(network: string, serialNumbers: string[]) { + const networks = this.context.globalState.get>(this.keys.lastSeenDevicesByNetwork) || {}; + if (serialNumbers.length === 0) { + delete networks[network]; + } else { + networks[network] = { serialNumbers: serialNumbers, lastSeen: Date.now() }; + } + // Set to undefined if empty to keep storage clean + const value = Object.keys(networks).length === 0 ? undefined : networks; + void this.context.globalState.update(this.keys.lastSeenDevicesByNetwork, value); + } + + public addLastSeenDevice(network: string, serialNumber: string) { + const serialNumbers = this.getLastSeenDevices(network); + if (!serialNumbers.includes(serialNumber)) { + serialNumbers.push(serialNumber); + this.setLastSeenDevices(network, serialNumbers); + } + } + + public removeLastSeenDevice(network: string, serialNumber: string) { + const serialNumbers = this.getLastSeenDevices(network); + if (serialNumbers.includes(serialNumber)) { + this.setLastSeenDevices(network, serialNumbers.filter((existing) => existing !== serialNumber)); + } + } + + /** + * Clear all last seen devices for all networks + */ + public clearLastSeenDevices(): void { + void this.context.globalState.update(this.keys.lastSeenDevicesByNetwork, undefined); + } + + private expireOldLastSeenNetworks(networks: Record): Record { + const now = Date.now(); + for (const network in networks) { + if (now - networks[network].lastSeen > this.LAST_SEEN_NETWORK_EXPIRATION) { + delete networks[network]; + } + } + return networks; + } + + // ==================== Device Cache ==================== + + /** + * Get cached device details by serial number + */ + public getCachedDevice(serialNumber: string): CachedDevice | undefined { + const cache = this.context.globalState.get>(this.keys.deviceCache) || {}; + return cache[serialNumber]; + } + + /** + * Cache device details for future sessions + */ + public setCachedDevice(serialNumber: string, device: CachedDevice): void { + const cache = this.context.globalState.get>(this.keys.deviceCache) || {}; + cache[serialNumber] = device; + void this.context.globalState.update(this.keys.deviceCache, cache); + } + + /** + * Delete any device infos from the cache that were created more than LAST_SEEN_NETWORK_EXPIRATION ago + */ + public clearExpiredDevices() { + const cache = this.context.globalState.get>(this.keys.deviceCache) || {}; + const now = Date.now(); + for (const serialNumber in cache) { + if (now - cache[serialNumber].createdAt > this.LAST_SEEN_NETWORK_EXPIRATION) { + delete cache[serialNumber]; + } + } + void this.context.globalState.update(this.keys.deviceCache, cache); + } + + /** + * Remove a device from the cache + */ + public removeCachedDevice(serialNumber: string): void { + const cache = this.context.globalState.get>(this.keys.deviceCache) || {}; + delete cache[serialNumber]; + void this.context.globalState.update(this.keys.deviceCache, cache); + } + + /** + * Clear all cached devices + */ + public clearDeviceCache(): void { + void this.context.globalState.update(this.keys.deviceCache, undefined); + } + + // ==================== IP to Serial Number Mapping ==================== + + private LAST_AUDIT_TIME_SERIALNUMBER_BY_IP_FOR_NETWORK = 0; + + /** + * Get serial number for an IP address. + * When currentNetworkId is provided, ONLY checks that specific network (network-specific lookup). + * When currentNetworkId is NOT provided, searches all networks for the most recent entry. + * Used for host-only configured devices to look up cached device info. + */ + public getSerialNumberForIp(ip: string, currentNetworkId?: string): string | undefined { + this.clearExpiredEntriesSerialNumberByIpForNetwork(); + const map = this.context.globalState.get(this.keys.serialNumberByIpForNetwork) || {}; + + // When currentNetworkId is provided, only check that specific network (strict lookup) + if (currentNetworkId) { + const currentNetworkEntry = map[currentNetworkId]?.[ip]; + return currentNetworkEntry?.serialNumber; + } + + // No network specified - fall back to searching all networks, return most recent by timestamp + let mostRecent: { serialNumber: string; timestamp: number } | undefined; + for (const networkId in map) { + const networkMap = map[networkId]; + const entry = networkMap?.[ip]; + if (entry && (!mostRecent || entry.timestamp > mostRecent.timestamp)) { + mostRecent = entry; + } + } + + return mostRecent?.serialNumber; + } + + /** + * Save IP→serialNumber mapping for the specified network. Called when a device is successfully resolved. + * Ensures uniqueness: removes any existing entry with the same serial number (device moved IPs). + * IP uniqueness is implicit since IP is the key. + */ + public setSerialNumberForIp(networkId: string, ip: string, serialNumber: string): void { + const map = this.context.globalState.get(this.keys.serialNumberByIpForNetwork) || {}; + if (!map[networkId]) { + map[networkId] = {}; + } + + // Remove any existing entry with the same serial number (device moved IPs) + for (const existingIp in map[networkId]) { + if (map[networkId][existingIp].serialNumber === serialNumber && existingIp !== ip) { + delete map[networkId][existingIp]; + } + } + + map[networkId][ip] = { serialNumber: serialNumber, timestamp: Date.now() }; + void this.context.globalState.update(this.keys.serialNumberByIpForNetwork, map); + } + + /** + * Get the most recent IP address for a given serial number. + * Checks current network first, then falls back to any network. + */ + public getIpForSerial(serialNumber: string, currentNetworkId?: string): string | undefined { + this.clearExpiredEntriesSerialNumberByIpForNetwork(); + const map = this.context.globalState.get(this.keys.serialNumberByIpForNetwork) || {}; + + // First try: Current network mapping + if (currentNetworkId && map[currentNetworkId]) { + for (const [ip, entry] of Object.entries(map[currentNetworkId])) { + if (entry.serialNumber === serialNumber) { + return ip; + } + } + } + + // Fallback: Any network, return the most recently updated IP for this serial + let mostRecent: { ip: string; timestamp: number } | undefined; + for (const networkId in map) { + const networkMap = map[networkId]; + for (const [ip, entry] of Object.entries(networkMap)) { + if (entry.serialNumber === serialNumber) { + if (!mostRecent || entry.timestamp > mostRecent.timestamp) { + mostRecent = { ip: ip, timestamp: entry.timestamp }; + } + } + } + } + + return mostRecent?.ip; + } + + /** + * Clear the IP→serialNumber map + */ + public clearSerialNumberByIpForNetwork(): void { + void this.context.globalState.update(this.keys.serialNumberByIpForNetwork, undefined); + } + + /** + * Clear expired entries from the IP→serialNumber map (same expiration as other cached data) + */ + public clearExpiredEntriesSerialNumberByIpForNetwork(): void { + const now = Date.now(); + if (now - this.LAST_AUDIT_TIME_SERIALNUMBER_BY_IP_FOR_NETWORK < 24 * 60 * 60 * 1_000) { + return; + } + this.LAST_AUDIT_TIME_SERIALNUMBER_BY_IP_FOR_NETWORK = now; + + const map = this.context.globalState.get(this.keys.serialNumberByIpForNetwork) || {}; + let changed = false; + + for (const networkId in map) { + const networkMap = map[networkId]; + for (const ip in networkMap) { + if (now - networkMap[ip].timestamp > this.LAST_SEEN_NETWORK_EXPIRATION) { + delete networkMap[ip]; + changed = true; + } + } + // Remove empty network entries + if (Object.keys(networkMap).length === 0) { + delete map[networkId]; + changed = true; + } + } + + if (changed) { + void this.context.globalState.update(this.keys.serialNumberByIpForNetwork, map); + } + } + + // ==================== Heartbeat / Last Alive Timestamp ==================== + + public getLastAliveTimestamp(key: string): number | undefined { + const map = this.context.globalState.get>(this.keys.lastAliveTimestamp) || {}; + return map[key]; + } + + public setLastAliveTimestamp(key: string, timestamp: number): void { + const map = this.context.globalState.get>(this.keys.lastAliveTimestamp) || {}; + map[key] = timestamp; + void this.context.globalState.update(this.keys.lastAliveTimestamp, map); + } +} + +// ==================== Interfaces ==================== + +interface LastSeenNetworkEntry { + serialNumbers: string[]; + lastSeen: number; +} + +/** + * Cached device details (RokuDevice without transient deviceState) + */ +export interface CachedDevice { + serialNumber: string; + deviceInfo: Record; + createdAt: number; +} + +/** + * Entry in the IP→serialNumber map + */ +interface IpToSerialNumberEntry { + serialNumber: string; + timestamp: number; +} + +/** + * Per-network IP→serialNumber mapping with timestamps + */ +type IpToSerialNumberMap = Record>; From b25e0bf827f274b83b08297ec027efd75d52f287 Mon Sep 17 00:00:00 2001 From: Christian Holbrook Date: Wed, 13 May 2026 21:29:11 -0600 Subject: [PATCH 2/2] Use the DeviceStorageManager --- src/BrightScriptCommands.ts | 2 +- src/DebugConfigurationProvider.spec.ts | 4 +- src/GlobalStateManager.spec.ts | 393 +++-------------- src/GlobalStateManager.ts | 273 +----------- src/deviceDiscovery/DeviceManager.spec.ts | 492 +++++++++++----------- src/deviceDiscovery/DeviceManager.ts | 78 ++-- src/deviceDiscovery/RokuFinder.spec.ts | 82 ++-- src/deviceDiscovery/RokuFinder.ts | 10 +- src/extension.ts | 2 +- src/managers/UserInputManager.spec.ts | 5 +- 10 files changed, 401 insertions(+), 940 deletions(-) diff --git a/src/BrightScriptCommands.ts b/src/BrightScriptCommands.ts index 008d877a..58510827 100644 --- a/src/BrightScriptCommands.ts +++ b/src/BrightScriptCommands.ts @@ -357,7 +357,7 @@ export class BrightScriptCommands { }); this.registerCommand('clearLastSeenDevices', async () => { - new GlobalStateManager(this.context).clearLastSeenDevices(); + this.deviceManager.clearLastSeenDevices(); await vscode.window.showInformationMessage('Last seen devices cleared'); }); diff --git a/src/DebugConfigurationProvider.spec.ts b/src/DebugConfigurationProvider.spec.ts index baf20f53..935416f3 100644 --- a/src/DebugConfigurationProvider.spec.ts +++ b/src/DebugConfigurationProvider.spec.ts @@ -10,7 +10,6 @@ import { vscode } from './mockVscode.spec'; import { standardizePath as s } from 'brighterscript'; import * as fsExtra from 'fs-extra'; import { DeviceManager } from './deviceDiscovery/DeviceManager'; -import { GlobalStateManager } from './GlobalStateManager'; import { rokuDeploy } from 'roku-deploy'; import { CredentialStore } from './managers/CredentialStore'; @@ -52,8 +51,7 @@ describe('BrightScriptConfigurationProvider', () => { sinon.stub(DeviceManager.prototype as any, 'setupConfiguration').callsFake(() => { }); sinon.stub(DeviceManager.prototype as any, 'setupWindowFocusHandling').callsFake(() => { }); sinon.stub(DeviceManager.prototype as any, 'setupMonitors').callsFake(() => { }); - const globalStateManager = new GlobalStateManager(vscode.context); - deviceManager = new DeviceManager(vscode.context, globalStateManager); + deviceManager = new DeviceManager(vscode.context); userInputManager = new UserInputManager(deviceManager); credentialStore = new CredentialStore(vscode.context); diff --git a/src/GlobalStateManager.spec.ts b/src/GlobalStateManager.spec.ts index 8a37f3e5..fbb0f8de 100644 --- a/src/GlobalStateManager.spec.ts +++ b/src/GlobalStateManager.spec.ts @@ -18,7 +18,6 @@ describe('GlobalStateManager', () => { let manager: GlobalStateManager; let mockContext: any; let storage: Record; - const testNetwork = 'test-network-hash'; beforeEach(() => { storage = {}; @@ -30,376 +29,82 @@ describe('GlobalStateManager', () => { } } }; - manager = new GlobalStateManager(mockContext); - }); - - describe('lastSeenDevices', () => { - it('returns empty array when no devices stored', () => { - expect(manager.getLastSeenDevices(testNetwork)).to.deep.equal([]); - }); - - it('adds a new device', () => { - manager.addLastSeenDevice(testNetwork, 'device-123'); - expect(manager.getLastSeenDevices(testNetwork)).to.deep.equal(['device-123']); - }); - - it('does not add duplicate devices', () => { - manager.addLastSeenDevice(testNetwork, 'device-123'); - manager.addLastSeenDevice(testNetwork, 'device-123'); - expect(manager.getLastSeenDevices(testNetwork)).to.deep.equal(['device-123']); - }); - - it('removes a device', () => { - manager.addLastSeenDevice(testNetwork, 'device-123'); - manager.addLastSeenDevice(testNetwork, 'device-456'); - manager.removeLastSeenDevice(testNetwork, 'device-123'); - expect(manager.getLastSeenDevices(testNetwork)).to.deep.equal(['device-456']); - }); - - it('keeps devices separate by network', () => { - const network1 = 'network-1'; - const network2 = 'network-2'; - manager.addLastSeenDevice(network1, 'device-123'); - manager.addLastSeenDevice(network2, 'device-456'); - expect(manager.getLastSeenDevices(network1)).to.deep.equal(['device-123']); - expect(manager.getLastSeenDevices(network2)).to.deep.equal(['device-456']); - }); - }); - - describe('deviceCache', () => { - const testDevice = { - serialNumber: 'device-123', - deviceInfo: { - 'device-id': 'device-123', - 'default-device-name': 'Roku Express' - }, - createdAt: Date.now() + // Configure text history as enabled with a limit via util.getConfiguration return shape + vscode.workspace._configuration = { + 'brightscript.sendRemoteTextHistory': { + enabled: true, + limit: 30 + } }; - - it('returns undefined when device not cached', () => { - expect(manager.getCachedDevice('device-123')).to.be.undefined; - }); - - it('caches and retrieves a device', () => { - manager.setCachedDevice('device-123', testDevice); - expect(manager.getCachedDevice('device-123')).to.deep.equal(testDevice); - }); - - it('removes a cached device', () => { - manager.setCachedDevice('device-123', testDevice); - manager.removeCachedDevice('device-123'); - expect(manager.getCachedDevice('device-123')).to.be.undefined; - }); - - it('keeps devices separate by serial number', () => { - const device1 = { ...testDevice, serialNumber: 'device-123' }; - const device2 = { ...testDevice, serialNumber: 'device-456' }; - manager.setCachedDevice('device-123', device1); - manager.setCachedDevice('device-456', device2); - expect(manager.getCachedDevice('device-123')).to.deep.equal(device1); - expect(manager.getCachedDevice('device-456')).to.deep.equal(device2); - }); + manager = new GlobalStateManager(mockContext); + // Enable text history for tests by directly setting the private property + (manager as any).remoteTextHistoryEnabled = true; + (manager as any).remoteTextHistoryLimit = 30; }); - describe('serialNumberByIpForNetwork', () => { - const network1 = 'network-hash-1'; - const network2 = 'network-hash-2'; - const serial1 = 'SERIAL001'; - const serial2 = 'SERIAL002'; - - it('stores a new IP→serial mapping', () => { - manager.setSerialNumberForIp(network1, '192.168.1.10', serial1); - expect(manager.getSerialNumberForIp('192.168.1.10', network1)).to.equal(serial1); - }); - - it('returns undefined for unknown IP', () => { - expect(manager.getSerialNumberForIp('10.0.0.1', network1)).to.be.undefined; - }); - - it('removes old IP entry when same serial is seen at a new IP on the same network', () => { - manager.setSerialNumberForIp(network1, '192.168.1.10', serial1); - manager.setSerialNumberForIp(network1, '192.168.1.20', serial1); - - // New IP should resolve correctly - expect(manager.getSerialNumberForIp('192.168.1.20', network1)).to.equal(serial1); - // Old IP should no longer exist in the cache - expect(manager.getSerialNumberForIp('192.168.1.10', network1)).to.be.undefined; - }); - - it('purges all stale entries when a serial accumulates multiple old IPs on the same network', () => { - // Simulate a Roku that changed IPs several times - manager.setSerialNumberForIp(network1, '192.168.1.10', serial1); - manager.setSerialNumberForIp(network1, '192.168.1.11', serial1); - manager.setSerialNumberForIp(network1, '192.168.1.12', serial1); - // Now it moves to a new IP - manager.setSerialNumberForIp(network1, '192.168.1.99', serial1); - - // Only the latest IP survives - expect(manager.getSerialNumberForIp('192.168.1.99', network1)).to.equal(serial1); - expect(manager.getSerialNumberForIp('192.168.1.10', network1)).to.be.undefined; - expect(manager.getSerialNumberForIp('192.168.1.11', network1)).to.be.undefined; - expect(manager.getSerialNumberForIp('192.168.1.12', network1)).to.be.undefined; - }); - - it('does not affect entries for a different serial on the same network', () => { - manager.setSerialNumberForIp(network1, '192.168.1.10', serial1); - manager.setSerialNumberForIp(network1, '192.168.1.20', serial2); - // Move serial1 to a new IP - manager.setSerialNumberForIp(network1, '192.168.1.30', serial1); - - expect(manager.getSerialNumberForIp('192.168.1.30', network1)).to.equal(serial1); - // serial2 entry is untouched - expect(manager.getSerialNumberForIp('192.168.1.20', network1)).to.equal(serial2); - }); - - it('does not remove entries for the same serial on a different network', () => { - manager.setSerialNumberForIp(network1, '192.168.1.10', serial1); - manager.setSerialNumberForIp(network2, '10.0.0.5', serial1); - // Update serial1 on network1 only - manager.setSerialNumberForIp(network1, '192.168.1.50', serial1); - - expect(manager.getSerialNumberForIp('192.168.1.50', network1)).to.equal(serial1); - expect(manager.getSerialNumberForIp('192.168.1.10', network1)).to.be.undefined; - // network2 entry should be untouched - expect(manager.getSerialNumberForIp('10.0.0.5', network2)).to.equal(serial1); + describe('lastRunExtensionVersion', () => { + it('returns undefined when not set', () => { + expect(manager.lastRunExtensionVersion).to.be.undefined; }); - it('getIpForSerial returns the most recent IP after deduplication', () => { - manager.setSerialNumberForIp(network1, '192.168.1.10', serial1); - manager.setSerialNumberForIp(network1, '192.168.1.20', serial1); - - expect(manager.getIpForSerial(serial1, network1)).to.equal('192.168.1.20'); - }); - - it('overwrites mapping when the same IP is re-used by the same serial', () => { - manager.setSerialNumberForIp(network1, '192.168.1.10', serial1); - manager.setSerialNumberForIp(network1, '192.168.1.10', serial1); - - expect(manager.getSerialNumberForIp('192.168.1.10', network1)).to.equal(serial1); + it('stores and retrieves a version', () => { + manager.lastRunExtensionVersion = '1.2.3'; + expect(manager.lastRunExtensionVersion).to.equal('1.2.3'); }); }); - describe('clearExpiredDevices', () => { - const testDevice = { - serialNumber: 'device-123', - deviceInfo: { - 'device-id': 'device-123', - 'default-device-name': 'Roku Express' - }, - createdAt: Date.now() - }; - - beforeEach(() => { - // Use a short expiration for testing (1 second) - (manager as any).LAST_SEEN_NETWORK_EXPIRATION = 1_000; + describe('lastSeenReleaseNotesVersion', () => { + it('returns undefined when not set', () => { + expect(manager.lastSeenReleaseNotesVersion).to.be.undefined; }); - it('keeps devices that are not expired', () => { - manager.setCachedDevice('device-123', { ...testDevice, createdAt: Date.now() }); - manager.clearExpiredDevices(); - expect(manager.getCachedDevice('device-123')).to.not.be.undefined; - }); - - it('removes devices that are expired', () => { - manager.setCachedDevice('device-123', { ...testDevice, createdAt: Date.now() - 2_000 }); - manager.clearExpiredDevices(); - expect(manager.getCachedDevice('device-123')).to.be.undefined; - }); - - it('removes only expired devices and keeps fresh ones', () => { - manager.setCachedDevice('old-device', { ...testDevice, serialNumber: 'old-device', createdAt: Date.now() - 2_000 }); - manager.setCachedDevice('new-device', { ...testDevice, serialNumber: 'new-device', createdAt: Date.now() }); - manager.clearExpiredDevices(); - expect(manager.getCachedDevice('old-device')).to.be.undefined; - expect(manager.getCachedDevice('new-device')).to.not.be.undefined; - }); - - it('handles empty cache', () => { - manager.clearExpiredDevices(); - expect(manager.getCachedDevice('device-123')).to.be.undefined; + it('stores and retrieves a version', () => { + manager.lastSeenReleaseNotesVersion = '2.0.0'; + expect(manager.lastSeenReleaseNotesVersion).to.equal('2.0.0'); }); }); - describe('setSerialNumberForIp', () => { - it('stores IP to serial mapping', () => { - manager.setSerialNumberForIp(testNetwork, '192.168.1.100', 'serial-123'); - expect(manager.getSerialNumberForIp('192.168.1.100', testNetwork)).to.equal('serial-123'); + describe('sendRemoteTextHistory', () => { + it('returns empty array when no history stored', () => { + expect(manager.sendRemoteTextHistory).to.deep.equal([]); }); - it('removes old IP entry when serial moves to new IP', () => { - manager.setSerialNumberForIp(testNetwork, '192.168.1.100', 'serial-123'); - manager.setSerialNumberForIp(testNetwork, '192.168.1.200', 'serial-123'); - - // Old IP should no longer have the serial - expect(manager.getSerialNumberForIp('192.168.1.100', testNetwork)).to.be.undefined; - // New IP should have the serial - expect(manager.getSerialNumberForIp('192.168.1.200', testNetwork)).to.equal('serial-123'); - }); - - it('allows same serial at same IP (update timestamp)', () => { - manager.setSerialNumberForIp(testNetwork, '192.168.1.100', 'serial-123'); - manager.setSerialNumberForIp(testNetwork, '192.168.1.100', 'serial-123'); - - // IP should still have the serial - expect(manager.getSerialNumberForIp('192.168.1.100', testNetwork)).to.equal('serial-123'); - }); - - it('allows different serials at different IPs', () => { - manager.setSerialNumberForIp(testNetwork, '192.168.1.100', 'serial-123'); - manager.setSerialNumberForIp(testNetwork, '192.168.1.200', 'serial-456'); - - expect(manager.getSerialNumberForIp('192.168.1.100', testNetwork)).to.equal('serial-123'); - expect(manager.getSerialNumberForIp('192.168.1.200', testNetwork)).to.equal('serial-456'); - }); - - it('replaces serial at same IP', () => { - manager.setSerialNumberForIp(testNetwork, '192.168.1.100', 'serial-123'); - manager.setSerialNumberForIp(testNetwork, '192.168.1.100', 'serial-456'); - - expect(manager.getSerialNumberForIp('192.168.1.100', testNetwork)).to.equal('serial-456'); + it('stores and retrieves history', () => { + manager.sendRemoteTextHistory = ['text1', 'text2']; + expect(manager.sendRemoteTextHistory).to.deep.equal(['text1', 'text2']); }); }); - describe('getIpForSerial', () => { - const networkA = 'network-hash-aaaaaa'; - const networkB = 'network-hash-bbbbbb'; - const networkC = 'network-hash-cccccc'; - - it('returns undefined when no mappings exist', () => { - expect(manager.getIpForSerial('ABC123')).to.be.undefined; + describe('addTextHistory', () => { + it('adds new text to history', () => { + manager.addTextHistory('hello'); + expect(manager.sendRemoteTextHistory).to.deep.equal(['hello']); }); - it('returns the IP when found in current network', () => { - manager.setSerialNumberForIp(networkA, '10.0.1.100', 'ABC123'); - expect(manager.getIpForSerial('ABC123', networkA)).to.equal('10.0.1.100'); + it('moves duplicate to front of history', () => { + manager.addTextHistory('first'); + manager.addTextHistory('second'); + manager.addTextHistory('first'); + expect(manager.sendRemoteTextHistory).to.deep.equal(['first', 'second']); }); - it('returns undefined when serial is not in current network and no other networks', () => { - manager.setSerialNumberForIp(networkA, '10.0.1.100', 'ABC123'); - expect(manager.getIpForSerial('OTHER999', networkA)).to.be.undefined; - }); - - it('falls back to another network when serial not found in current network', () => { - manager.setSerialNumberForIp(networkB, '10.0.2.200', 'ABC123'); - // networkA has no entry for ABC123 - expect(manager.getIpForSerial('ABC123', networkA)).to.equal('10.0.2.200'); - }); - - it('returns the most recently updated IP across multiple networks', () => { - const now = Date.now(); - // Manually inject entries with specific timestamps to control ordering - const key = 'serialNumberByIpForNetwork'; - storage[key] = { - [networkA]: { - '10.0.1.100': { serialNumber: 'ABC123', timestamp: now - 5_000 } - }, - [networkB]: { - '10.0.2.200': { serialNumber: 'ABC123', timestamp: now - 1_000 } - }, - [networkC]: { - '10.0.3.300': { serialNumber: 'ABC123', timestamp: now - 3_000 } - } - }; - - // Should pick the most recently updated IP (from networkB) - expect(manager.getIpForSerial('ABC123')).to.equal('10.0.2.200'); - }); - - it('returns current network IP even if another network has a more recent timestamp', () => { - const now = Date.now(); - const key = 'serialNumberByIpForNetwork'; - storage[key] = { - [networkA]: { - '10.0.1.100': { serialNumber: 'ABC123', timestamp: now - 10_000 } - }, - [networkB]: { - '10.0.2.200': { serialNumber: 'ABC123', timestamp: now - 1_000 } - } - }; - - // Current network A has an older timestamp, but it should be preferred - expect(manager.getIpForSerial('ABC123', networkA)).to.equal('10.0.1.100'); - }); - - it('returns the most recently updated IP when same serial appears in multiple networks with no current network', () => { - const now = Date.now(); - const key = 'serialNumberByIpForNetwork'; - storage[key] = { - [networkA]: { - '10.0.1.100': { serialNumber: 'ABC123', timestamp: now - 9_000 }, - '10.0.1.101': { serialNumber: 'XYZ789', timestamp: now - 2_000 } - }, - [networkB]: { - '10.0.2.200': { serialNumber: 'ABC123', timestamp: now - 500 } - } - }; - - // No current network specified - should return most recent across all networks - expect(manager.getIpForSerial('ABC123')).to.equal('10.0.2.200'); - }); - - it('does not confuse different serial numbers in the same network', () => { - manager.setSerialNumberForIp(networkA, '10.0.1.100', 'ABC123'); - manager.setSerialNumberForIp(networkA, '10.0.1.101', 'XYZ789'); - - expect(manager.getIpForSerial('ABC123', networkA)).to.equal('10.0.1.100'); - expect(manager.getIpForSerial('XYZ789', networkA)).to.equal('10.0.1.101'); + it('does not add empty string', () => { + manager.addTextHistory(''); + expect(manager.sendRemoteTextHistory).to.deep.equal([]); }); }); - describe('getSerialNumberForIp', () => { - const networkA = 'network-hash-aaaaaa'; - const networkB = 'network-hash-bbbbbb'; - const networkC = 'network-hash-cccccc'; + describe('clear', () => { + it('clears all stored values', () => { + manager.lastRunExtensionVersion = '1.0.0'; + manager.lastSeenReleaseNotesVersion = '2.0.0'; + manager.sendRemoteTextHistory = ['text']; - it('returns undefined when no mappings exist', () => { - expect(manager.getSerialNumberForIp('10.0.1.100')).to.be.undefined; - }); - - it('returns the serial number when found in current network', () => { - manager.setSerialNumberForIp(networkA, '10.0.1.100', 'ABC123'); - expect(manager.getSerialNumberForIp('10.0.1.100', networkA)).to.equal('ABC123'); - }); - - it('falls back to another network when IP not found in current network', () => { - manager.setSerialNumberForIp(networkB, '10.0.2.200', 'XYZ789'); - // networkA has no entry for this IP - expect(manager.getSerialNumberForIp('10.0.2.200', networkA)).to.equal('XYZ789'); - }); - - it('returns the most recently updated serial across multiple networks', () => { - const now = Date.now(); - const key = 'serialNumberByIpForNetwork'; - storage[key] = { - [networkA]: { - '10.0.1.100': { serialNumber: 'OLD123', timestamp: now - 5_000 } - }, - [networkB]: { - '10.0.1.100': { serialNumber: 'NEW456', timestamp: now - 1_000 } - }, - [networkC]: { - '10.0.1.100': { serialNumber: 'MID789', timestamp: now - 3_000 } - } - }; - - // No current network - should return most recent by timestamp - expect(manager.getSerialNumberForIp('10.0.1.100')).to.equal('NEW456'); - }); - - it('returns current network serial even if another network has a more recent entry for the same IP', () => { - const now = Date.now(); - const key = 'serialNumberByIpForNetwork'; - storage[key] = { - [networkA]: { - '10.0.1.100': { serialNumber: 'LOCAL123', timestamp: now - 10_000 } - }, - [networkB]: { - '10.0.1.100': { serialNumber: 'REMOTE456', timestamp: now - 1_000 } - } - }; + manager.clear(); - // Current network A preferred over the more recent networkB entry - expect(manager.getSerialNumberForIp('10.0.1.100', networkA)).to.equal('LOCAL123'); + expect(manager.lastRunExtensionVersion).to.be.undefined; + expect(manager.lastSeenReleaseNotesVersion).to.be.undefined; + expect(manager.sendRemoteTextHistory).to.deep.equal([]); }); }); }); diff --git a/src/GlobalStateManager.ts b/src/GlobalStateManager.ts index 73d547bc..7f4154c4 100644 --- a/src/GlobalStateManager.ts +++ b/src/GlobalStateManager.ts @@ -14,11 +14,7 @@ export class GlobalStateManager { lastSeenReleaseNotesVersion: 'lastSeenReleaseNotesVersion', sendRemoteTextHistory: 'sendRemoteTextHistory', debugProtocolPopupSnoozeUntilDate: 'debugProtocolPopupSnoozeUntilDate', - debugProtocolPopupSnoozeValue: 'debugProtocolPopupSnoozeValue', - lastSeenDevicesByNetwork: 'lastSeenDevicesByNetwork', - deviceCache: 'deviceCache', - serialNumberByIpForNetwork: 'serialNumberByIpForNetwork', - lastAliveTimestamp: 'lastAliveTimestamp' + debugProtocolPopupSnoozeValue: 'debugProtocolPopupSnoozeValue' }; private remoteTextHistoryLimit: number; private remoteTextHistoryEnabled: boolean; @@ -72,245 +68,6 @@ export class GlobalStateManager { } } - public getLastSeenDevices(network: string): string[] { - const networks = this.context.globalState.get>(this.keys.lastSeenDevicesByNetwork) || {}; - const entry = networks[network]; - const serialNumbers = entry?.serialNumbers ?? []; - if (serialNumbers.length !== 0) { - networks[network] = { serialNumbers: serialNumbers, lastSeen: Date.now() }; - void this.context.globalState.update(this.keys.lastSeenDevicesByNetwork, this.expireOldLastSeenNetworks(networks)); - } - return serialNumbers; - } - - public setLastSeenDevices(network: string, serialNumbers: string[]) { - const networks = this.context.globalState.get>(this.keys.lastSeenDevicesByNetwork) || {}; - if (serialNumbers.length === 0) { - delete networks[network]; - } else { - networks[network] = { serialNumbers: serialNumbers, lastSeen: Date.now() }; - } - void this.context.globalState.update(this.keys.lastSeenDevicesByNetwork, networks); - } - - public addLastSeenDevice(network: string, serialNumber: string) { - const serialNumbers = this.getLastSeenDevices(network); - if (!serialNumbers.includes(serialNumber)) { - serialNumbers.push(serialNumber); - this.setLastSeenDevices(network, serialNumbers); - } - } - - public removeLastSeenDevice(network: string, serialNumber: string) { - const serialNumbers = this.getLastSeenDevices(network); - if (serialNumbers.includes(serialNumber)) { - this.setLastSeenDevices(network, serialNumbers.filter((existing) => existing !== serialNumber)); - } - } - - /** - * Get cached device details by serial number - */ - public getCachedDevice(serialNumber: string): CachedDevice | undefined { - const cache = this.context.globalState.get>(this.keys.deviceCache) || {}; - return cache[serialNumber]; - } - - /** - * Cache device details for future sessions - */ - public setCachedDevice(serialNumber: string, device: CachedDevice): void { - const cache = this.context.globalState.get>(this.keys.deviceCache) || {}; - cache[serialNumber] = device; - void this.context.globalState.update(this.keys.deviceCache, cache); - } - - private LAST_SEEN_NETWORK_EXPIRATION = 30 * 24 * 60 * 60 * 1_000; // 30 days - - /** - * Delete any device infos from the cache that were created more than LAST_SEEN_NETWORK_EXPIRATION ago - */ - public clearExpiredDevices() { - const cache = this.context.globalState.get>(this.keys.deviceCache) || {}; - const now = Date.now(); - for (const serialNumber in cache) { - if (now - cache[serialNumber].createdAt > this.LAST_SEEN_NETWORK_EXPIRATION) { - delete cache[serialNumber]; - } - } - void this.context.globalState.update(this.keys.deviceCache, cache); - } - - /** - * Remove a device from the cache - */ - public removeCachedDevice(serialNumber: string): void { - const cache = this.context.globalState.get>(this.keys.deviceCache) || {}; - delete cache[serialNumber]; - void this.context.globalState.update(this.keys.deviceCache, cache); - } - - /** - * Clear all cached devices - */ - public clearDeviceCache(): void { - void this.context.globalState.update(this.keys.deviceCache, undefined); - } - - /** - * Clear all last seen devices for all networks - */ - public clearLastSeenDevices(): void { - void this.context.globalState.update(this.keys.lastSeenDevicesByNetwork, undefined); - } - - /** - * Get serial number for an IP address. - * First checks the current network, then falls back to searching all networks for the most recent entry. - * Used for host-only configured devices to look up cached device info. - */ - public getSerialNumberForIp(ip: string, currentNetworkId?: string): string | undefined { - this.clearExpiredEntriesSerialNumberByIpForNetwork(); - const map = this.context.globalState.get(this.keys.serialNumberByIpForNetwork) || {}; - // First, check the current network - if (currentNetworkId) { - const currentNetworkEntry = map[currentNetworkId]?.[ip]; - if (currentNetworkEntry) { - return currentNetworkEntry.serialNumber; - } - } - - // Fall back to searching all networks, return most recent by timestamp - let mostRecent: { serialNumber: string; timestamp: number } | undefined; - for (const networkId in map) { - const networkMap = map[networkId]; - const entry = networkMap?.[ip]; - if (entry && (!mostRecent || entry.timestamp > mostRecent.timestamp)) { - mostRecent = entry; - } - } - - return mostRecent?.serialNumber; - } - - /** - * Save IP→serialNumber mapping for the specified network. Called when a device is successfully resolved. - * Ensures uniqueness: removes any existing entry with the same serial number (device moved IPs). - * IP uniqueness is implicit since IP is the key. - */ - public setSerialNumberForIp(networkId: string, ip: string, serialNumber: string): void { - const map = this.context.globalState.get(this.keys.serialNumberByIpForNetwork) || {}; - if (!map[networkId]) { - map[networkId] = {}; - } - - // Remove any existing entry with the same serial number (device moved IPs) - for (const existingIp in map[networkId]) { - if (map[networkId][existingIp].serialNumber === serialNumber && existingIp !== ip) { - delete map[networkId][existingIp]; - } - } - - map[networkId][ip] = { serialNumber: serialNumber, timestamp: Date.now() }; - void this.context.globalState.update(this.keys.serialNumberByIpForNetwork, map); - } - - /** - * Get the most recent IP address for a given serial number. - * Checks current network first, then falls back to any network. - */ - public getIpForSerial(serialNumber: string, currentNetworkId?: string): string | undefined { - this.clearExpiredEntriesSerialNumberByIpForNetwork(); - const map = this.context.globalState.get(this.keys.serialNumberByIpForNetwork) || {}; - - // First try: Current network mapping - if (currentNetworkId && map[currentNetworkId]) { - for (const [ip, entry] of Object.entries(map[currentNetworkId])) { - if (entry.serialNumber === serialNumber) { - return ip; - } - } - } - - // Fallback: Any network, return the most recently updated IP for this serial - let mostRecent: { ip: string; timestamp: number } | undefined; - for (const networkId in map) { - const networkMap = map[networkId]; - for (const [ip, entry] of Object.entries(networkMap)) { - if (entry.serialNumber === serialNumber) { - if (!mostRecent || entry.timestamp > mostRecent.timestamp) { - mostRecent = { ip: ip, timestamp: entry.timestamp }; - } - } - } - } - - return mostRecent?.ip; - } - - /** - * Clear the IP→serialNumber map - */ - public clearSerialNumberByIpForNetwork(): void { - void this.context.globalState.update(this.keys.serialNumberByIpForNetwork, undefined); - } - - private LAST_AUDIT_TIME_SERIALNUMBER_BY_IP_FOR_NETWORK = 0; - /** - * Clear expired entries from the IP→serialNumber map (same expiration as other cached data) - */ - public clearExpiredEntriesSerialNumberByIpForNetwork(): void { - const now = Date.now(); - if (now - this.LAST_AUDIT_TIME_SERIALNUMBER_BY_IP_FOR_NETWORK < 24 * 60 * 60 * 1_000) { - return; - } - this.LAST_AUDIT_TIME_SERIALNUMBER_BY_IP_FOR_NETWORK = now; - - const map = this.context.globalState.get(this.keys.serialNumberByIpForNetwork) || {}; - let changed = false; - - for (const networkId in map) { - const networkMap = map[networkId]; - for (const ip in networkMap) { - if (now - networkMap[ip].timestamp > this.LAST_SEEN_NETWORK_EXPIRATION) { - delete networkMap[ip]; - changed = true; - } - } - // Remove empty network entries - if (Object.keys(networkMap).length === 0) { - delete map[networkId]; - changed = true; - } - } - - if (changed) { - void this.context.globalState.update(this.keys.serialNumberByIpForNetwork, map); - } - } - - - private expireOldLastSeenNetworks(networks: Record): Record { - const now = Date.now(); - for (const network in networks) { - if (now - networks[network].lastSeen > this.LAST_SEEN_NETWORK_EXPIRATION) { - delete networks[network]; - } - } - return networks; - } - - public getLastAliveTimestamp(key: string): number | undefined { - const map = this.context.globalState.get>(this.keys.lastAliveTimestamp) || {}; - return map[key]; - } - - public setLastAliveTimestamp(key: string, timestamp: number): void { - const map = this.context.globalState.get>(this.keys.lastAliveTimestamp) || {}; - map[key] = timestamp; - void this.context.globalState.update(this.keys.lastAliveTimestamp, map); - } - /** * Clear all known global state values for this extension */ @@ -321,31 +78,3 @@ export class GlobalStateManager { } } } - -interface LastSeenNetworkEntry { - serialNumbers: string[]; - lastSeen: number; -} - -/** - * Cached device details (RokuDevice without transient deviceState) - */ -export interface CachedDevice { - serialNumber: string; - deviceInfo: Record; - createdAt: number; -} - - -/** - * Entry in the IP→serialNumber map - */ -interface IpToSerialNumberEntry { - serialNumber: string; - timestamp: number; -} - -/** - * Per-network IP→serialNumber mapping with timestamps - */ -type IpToSerialNumberMap = Record>; diff --git a/src/deviceDiscovery/DeviceManager.spec.ts b/src/deviceDiscovery/DeviceManager.spec.ts index c28a670e..bf2a77aa 100644 --- a/src/deviceDiscovery/DeviceManager.spec.ts +++ b/src/deviceDiscovery/DeviceManager.spec.ts @@ -9,7 +9,72 @@ import { util } from '../util'; describe('DeviceManager', () => { let manager: DeviceManager; - let mockGlobalStateManager: any; + + /** + * Helper to get/update global state data for testing. + * Uses the mock vscode.context.globalState storage. + */ + const globalStateData = () => vscode.context.globalState['_data']; + + /** + * Set a cached device in the global state storage + */ + function setCachedDevice(serialNumber: string, device: any): void { + const cache = globalStateData()['deviceCache'] || {}; + cache[serialNumber] = device; + globalStateData()['deviceCache'] = cache; + } + + /** + * Set IP→serial mapping in the global state storage + */ + function setSerialNumberForIp(networkId: string, ip: string, serialNumber: string): void { + const map = globalStateData()['serialNumberByIpForNetwork'] || {}; + if (!map[networkId]) { + map[networkId] = {}; + } + map[networkId][ip] = { serialNumber: serialNumber, timestamp: Date.now() }; + globalStateData()['serialNumberByIpForNetwork'] = map; + } + + /** + * Get a cached device from the global state storage + */ + function getCachedDevice(serialNumber: string): any { + const cache = globalStateData()['deviceCache'] || {}; + return cache[serialNumber]; + } + + /** + * Get last seen devices from the global state storage + */ + function getLastSeenDevices(networkId: string): string[] { + const networks = globalStateData()['lastSeenDevicesByNetwork'] || {}; + return networks[networkId]?.serialNumbers ?? []; + } + + /** + * Set last seen devices in the global state storage + */ + function setLastSeenDevices(networkId: string, serialNumbers: string[]): void { + const networks = globalStateData()['lastSeenDevicesByNetwork'] || {}; + networks[networkId] = { serialNumbers: serialNumbers, lastSeen: Date.now() }; + globalStateData()['lastSeenDevicesByNetwork'] = networks; + } + + /** + * Clear device cache from the global state storage + */ + function clearDeviceCache(): void { + delete globalStateData()['deviceCache']; + } + + /** + * Clear IP→serial mapping from the global state storage + */ + function clearSerialNumberByIpForNetwork(): void { + delete globalStateData()['serialNumberByIpForNetwork']; + } function createMockDevice(overrides: Partial & { deviceInfo?: any; serialNumber?: string | null } = {}): RokuDevice { // Explicit null means no serial, undefined means use default @@ -29,8 +94,8 @@ describe('DeviceManager', () => { ...overrides.deviceInfo, 'serial-number': serialNumber // Serial goes in deviceInfo - after spread to not be overwritten }; - // Directly store in cache (setCachedDevice uses callsFake to store in map) - mockGlobalStateManager.setCachedDevice(serialNumber, { + // Directly store in cache + setCachedDevice(serialNumber, { serialNumber: serialNumber, deviceInfo: deviceInfo, createdAt: Date.now() @@ -39,7 +104,7 @@ describe('DeviceManager', () => { // Set up IP→serial mapping when serialNumber is provided if (serialNumber) { - mockGlobalStateManager.setSerialNumberForIp('test-network-hash', ip, serialNumber); + setSerialNumberForIp('test-network-hash', ip, serialNumber); } // Compute key same as setDevice @@ -98,51 +163,6 @@ describe('DeviceManager', () => { } beforeEach(() => { - // Map to track IP→serial mappings across the test - const ipToSerialMap = new Map(); - // Map to track cached devices - const deviceCache = new Map(); - - // Mock GlobalStateManager - mockGlobalStateManager = { - getLastSeenDevices: sinon.stub().returns([]), - setLastSeenDevices: sinon.stub(), - addLastSeenDevice: sinon.stub(), - removeLastSeenDevice: sinon.stub(), - setLastSeenDeviceIds: sinon.stub(), - getCachedDevice: sinon.stub().callsFake((serial) => { - return deviceCache.get(serial); - }), - setCachedDevice: sinon.stub().callsFake((serial, device) => { - deviceCache.set(serial, device); - }), - removeCachedDevice: sinon.stub(), - clearExpiredDevices: sinon.stub(), - getSerialNumberForIp: sinon.stub().callsFake((ip, networkId) => { - return ipToSerialMap.get(`${networkId}:${ip}`); - }), - setSerialNumberForIp: sinon.stub().callsFake((networkId, ip, serial) => { - ipToSerialMap.set(`${networkId}:${ip}`, serial); - }), - getIpForSerial: sinon.stub().callsFake((serial, networkId) => { - // Reverse lookup in ipToSerialMap - for (const [key, value] of ipToSerialMap.entries()) { - if (value === serial && key.startsWith(networkId + ':')) { - return key.split(':')[1]; - } - } - return undefined; - }), - clearLastSeenDevices: sinon.stub(), - clearDeviceCache: sinon.stub().callsFake(() => { - deviceCache.clear(); - }), - clearSerialNumberByIpForNetwork: sinon.stub().callsFake(() => { - ipToSerialMap.clear(); - }), - clearExpiredEntriesSerialNumberByIpForNetwork: sinon.stub() - }; - // Mock vscode configuration sinon.stub(vscode.workspace, 'getConfiguration').returns({ get: () => undefined, @@ -168,7 +188,7 @@ describe('DeviceManager', () => { describe('setScanNeeded', () => { it('emits event when scanNeeded is false', () => { - manager = new DeviceManager(vscode.context, mockGlobalStateManager); + manager = new DeviceManager(vscode.context); const eventSpy = sinon.spy(); manager.on('scanNeeded-changed', eventSpy); @@ -179,7 +199,7 @@ describe('DeviceManager', () => { }); it('does not emit event when scanNeeded is already true', () => { - manager = new DeviceManager(vscode.context, mockGlobalStateManager); + manager = new DeviceManager(vscode.context); manager['setScanNeeded'](); // Set to true first const eventSpy = sinon.spy(); @@ -194,14 +214,14 @@ describe('DeviceManager', () => { describe('timeSinceLastScan', () => { it('returns Infinity when no scan has occurred', () => { - manager = new DeviceManager(vscode.context, mockGlobalStateManager); + manager = new DeviceManager(vscode.context); expect(manager['timeSinceLastScan']).to.equal(Infinity); }); it('returns elapsed time after refresh', () => { const clock = sinon.useFakeTimers(); try { - manager = new DeviceManager(vscode.context, mockGlobalStateManager); + manager = new DeviceManager(vscode.context); manager.refresh(true); @@ -216,7 +236,7 @@ describe('DeviceManager', () => { describe('on', () => { it('registers handler and returns unsubscribe function', () => { - manager = new DeviceManager(vscode.context, mockGlobalStateManager); + manager = new DeviceManager(vscode.context); const handler = sinon.spy(); const unsubscribe = manager.on('scanNeeded-changed', handler); @@ -233,7 +253,7 @@ describe('DeviceManager', () => { }); it('adds to disposables array if provided', () => { - manager = new DeviceManager(vscode.context, mockGlobalStateManager); + manager = new DeviceManager(vscode.context); const disposables: any[] = []; manager.on('scanNeeded-changed', () => { }, disposables); @@ -245,12 +265,12 @@ describe('DeviceManager', () => { describe('getActiveDevices', () => { it('returns empty array when no devices', () => { - manager = new DeviceManager(vscode.context, mockGlobalStateManager); + manager = new DeviceManager(vscode.context); expect(manager.getAllDevices()).to.deep.equal([]); }); it('sorts devices: sticks first, then boxes, then TVs', () => { - manager = new DeviceManager(vscode.context, mockGlobalStateManager); + manager = new DeviceManager(vscode.context); const tv = createMockDevice({ serialNumber: 'tv-1', @@ -281,7 +301,7 @@ describe('DeviceManager', () => { }); it('sorts by name within same form factor', () => { - manager = new DeviceManager(vscode.context, mockGlobalStateManager); + manager = new DeviceManager(vscode.context); const boxB = createMockDevice({ serialNumber: 'box-b', @@ -313,7 +333,7 @@ describe('DeviceManager', () => { describe('refresh', () => { it('resets scanNeeded flag (allows event to fire again)', () => { - manager = new DeviceManager(vscode.context, mockGlobalStateManager); + manager = new DeviceManager(vscode.context); const eventSpy = sinon.spy(); manager.on('scanNeeded-changed', eventSpy); @@ -332,7 +352,7 @@ describe('DeviceManager', () => { }); it('sets lastScanDate', () => { - manager = new DeviceManager(vscode.context, mockGlobalStateManager); + manager = new DeviceManager(vscode.context); expect(manager['timeSinceLastScan']).to.equal(Infinity); @@ -343,7 +363,7 @@ describe('DeviceManager', () => { }); it('calls healthCheckAllDevices with force flag', () => { - manager = new DeviceManager(vscode.context, mockGlobalStateManager); + manager = new DeviceManager(vscode.context); const healthCheckAllDevicesSpy = sinon.stub(manager as any, 'healthCheckAllDevices').resolves(); @@ -360,7 +380,7 @@ describe('DeviceManager', () => { describe('scan', () => { it('triggers discovery without health checking', () => { - manager = new DeviceManager(vscode.context, mockGlobalStateManager); + manager = new DeviceManager(vscode.context); const discoverAllSpy = sinon.spy(manager as any, 'discoverAll'); const healthCheckAllDevicesSpy = sinon.spy(manager as any, 'healthCheckAllDevices'); @@ -372,7 +392,7 @@ describe('DeviceManager', () => { }); it('respects deviceDiscoveryEnabled when force=false', () => { - manager = new DeviceManager(vscode.context, mockGlobalStateManager); + manager = new DeviceManager(vscode.context); sinon.stub(manager as any, 'deviceDiscoveryEnabled').get(() => false); const discoverAllSpy = sinon.spy(manager as any, 'discoverAll'); @@ -384,7 +404,7 @@ describe('DeviceManager', () => { }); it('ignores deviceDiscoveryEnabled when force=true', () => { - manager = new DeviceManager(vscode.context, mockGlobalStateManager); + manager = new DeviceManager(vscode.context); sinon.stub(manager as any, 'deviceDiscoveryEnabled').get(() => false); const discoverAllSpy = sinon.spy(manager as any, 'discoverAll'); @@ -398,7 +418,7 @@ describe('DeviceManager', () => { it('emits scan-started event', () => { const clock = sinon.useFakeTimers(); try { - manager = new DeviceManager(vscode.context, mockGlobalStateManager); + manager = new DeviceManager(vscode.context); const scanStartedSpy = sinon.spy(); manager.on('scan-started', scanStartedSpy); @@ -414,7 +434,7 @@ describe('DeviceManager', () => { describe('healthCheckAllDevices', () => { it('sets all devices to pending and checks all when force=true', async () => { - manager = new DeviceManager(vscode.context, mockGlobalStateManager); + manager = new DeviceManager(vscode.context); const device1 = createMockDevice({ serialNumber: 'device-1', ip: '192.168.1.101' }); const device2 = createMockDevice({ serialNumber: 'device-2', ip: '192.168.1.102' }); @@ -429,7 +449,7 @@ describe('DeviceManager', () => { }); it('calls resolveDevice for all devices (caching happens in resolveDevice)', async () => { - manager = new DeviceManager(vscode.context, mockGlobalStateManager); + manager = new DeviceManager(vscode.context); const device1 = createMockDevice({ serialNumber: 'device-1', ip: '192.168.1.101' }); const device2 = createMockDevice({ serialNumber: 'device-2', ip: '192.168.1.102' }); @@ -445,7 +465,7 @@ describe('DeviceManager', () => { }); it('sets devices to pending before checking when force=false', async () => { - manager = new DeviceManager(vscode.context, mockGlobalStateManager); + manager = new DeviceManager(vscode.context); const device = createMockDevice(); addDevice(device); @@ -463,7 +483,7 @@ describe('DeviceManager', () => { }); it('resolveDevice uses cached data when recently fetched', async () => { - manager = new DeviceManager(vscode.context, mockGlobalStateManager); + manager = new DeviceManager(vscode.context); const device = createMockDevice(); addDevice(device); @@ -491,7 +511,7 @@ describe('DeviceManager', () => { describe('healthCheckDevice with force=false (cooldown)', () => { it('skips network fetch if within cooldown period (uses cached data)', async () => { - manager = new DeviceManager(vscode.context, mockGlobalStateManager); + manager = new DeviceManager(vscode.context); const device = createMockDevice(); addDevice(device); @@ -517,7 +537,7 @@ describe('DeviceManager', () => { it('fetches again after cooldown expires', async () => { const clock = sinon.useFakeTimers(Date.now()); try { - manager = new DeviceManager(vscode.context, mockGlobalStateManager); + manager = new DeviceManager(vscode.context); const device = createMockDevice(); addDevice(device); @@ -547,7 +567,7 @@ describe('DeviceManager', () => { }); it('always fetches when force=true regardless of cooldown', async () => { - manager = new DeviceManager(vscode.context, mockGlobalStateManager); + manager = new DeviceManager(vscode.context); const device = createMockDevice(); addDevice(device); @@ -575,7 +595,7 @@ describe('DeviceManager', () => { it('emits scan-started when scan begins', () => { const clock = sinon.useFakeTimers(); try { - manager = new DeviceManager(vscode.context, mockGlobalStateManager); + manager = new DeviceManager(vscode.context); const scanStartedSpy = sinon.spy(); manager.on('scan-started', scanStartedSpy); @@ -591,7 +611,7 @@ describe('DeviceManager', () => { it('emits scan-ended after min duration and settle time', () => { const clock = sinon.useFakeTimers(); try { - manager = new DeviceManager(vscode.context, mockGlobalStateManager); + manager = new DeviceManager(vscode.context); const scanEndedSpy = sinon.spy(); manager.on('scan-ended', scanEndedSpy); @@ -616,7 +636,7 @@ describe('DeviceManager', () => { it('does not start new scan if already scanning', () => { const clock = sinon.useFakeTimers(); try { - manager = new DeviceManager(vscode.context, mockGlobalStateManager); + manager = new DeviceManager(vscode.context); const scanStartedSpy = sinon.spy(); manager.on('scan-started', scanStartedSpy); @@ -635,7 +655,7 @@ describe('DeviceManager', () => { it('can start new scan after previous scan ends', () => { const clock = sinon.useFakeTimers(); try { - manager = new DeviceManager(vscode.context, mockGlobalStateManager); + manager = new DeviceManager(vscode.context); const scanStartedSpy = sinon.spy(); const scanEndedSpy = sinon.spy(); @@ -662,7 +682,7 @@ describe('DeviceManager', () => { it('emits when device is added', () => { const clock = sinon.useFakeTimers(); try { - manager = new DeviceManager(vscode.context, mockGlobalStateManager); + manager = new DeviceManager(vscode.context); // Wait for initial throttle window from constructor's loadLastSeenDevices clock.tick(400); @@ -684,7 +704,7 @@ describe('DeviceManager', () => { it('emits when device is removed', () => { const clock = sinon.useFakeTimers(); try { - manager = new DeviceManager(vscode.context, mockGlobalStateManager); + manager = new DeviceManager(vscode.context); // Add a device first const device = createMockDevice(); @@ -709,7 +729,7 @@ describe('DeviceManager', () => { it('throttles multiple rapid changes', () => { const clock = sinon.useFakeTimers(); try { - manager = new DeviceManager(vscode.context, mockGlobalStateManager); + manager = new DeviceManager(vscode.context); // Wait for initial throttle window from constructor's loadLastSeenDevices clock.tick(400); @@ -744,7 +764,7 @@ describe('DeviceManager', () => { describe('healthCheckDevice', () => { it('sets device to pending during health check', async () => { - manager = new DeviceManager(vscode.context, mockGlobalStateManager); + manager = new DeviceManager(vscode.context); (vscode.window as any).state = { focused: true }; const device = createMockDevice(); @@ -775,7 +795,7 @@ describe('DeviceManager', () => { }); it('removes device when health check fails', async () => { - manager = new DeviceManager(vscode.context, mockGlobalStateManager); + manager = new DeviceManager(vscode.context); (vscode.window as any).state = { focused: true }; const device = createMockDevice(); @@ -790,7 +810,7 @@ describe('DeviceManager', () => { }); it('preserves cache data when device goes offline (for offline display)', async () => { - manager = new DeviceManager(vscode.context, mockGlobalStateManager); + manager = new DeviceManager(vscode.context); (vscode.window as any).state = { focused: true }; const device = createMockDevice({ @@ -804,7 +824,7 @@ describe('DeviceManager', () => { await manager.healthCheckDevice(device, true); // Cache should still exist with device info preserved for offline display - const cached = mockGlobalStateManager.getCachedDevice('device-123'); + const cached = getCachedDevice('device-123'); expect(cached).to.exist; expect(cached.deviceInfo['default-device-name']).to.equal('My Roku'); @@ -813,7 +833,7 @@ describe('DeviceManager', () => { }); it('returns true when device is healthy', async () => { - manager = new DeviceManager(vscode.context, mockGlobalStateManager); + manager = new DeviceManager(vscode.context); const device = createMockDevice({ serialNumber: 'device-123' }); addDevice(device); @@ -830,7 +850,7 @@ describe('DeviceManager', () => { }); it('ignores stale health check response when newer check completes first', async () => { - manager = new DeviceManager(vscode.context, mockGlobalStateManager); + manager = new DeviceManager(vscode.context); (vscode.window as any).state = { focused: true }; const device = createMockDevice({ serialNumber: 'device-123' }); @@ -881,7 +901,7 @@ describe('DeviceManager', () => { }); it('tracks sequence numbers independently per device', async () => { - manager = new DeviceManager(vscode.context, mockGlobalStateManager); + manager = new DeviceManager(vscode.context); (vscode.window as any).state = { focused: true }; const device1 = createMockDevice({ serialNumber: 'device-1', ip: '192.168.1.101' }); @@ -936,7 +956,7 @@ describe('DeviceManager', () => { let validateStub: sinon.SinonStub; beforeEach(() => { - manager = new DeviceManager(vscode.context, mockGlobalStateManager); + manager = new DeviceManager(vscode.context); validateStub = sinon.stub(rokuDeploy, 'validateDeveloperPassword'); }); @@ -976,7 +996,7 @@ describe('DeviceManager', () => { it('clears lastUsedDeviceIp when removed device matches', () => { const clock = sinon.useFakeTimers(); try { - manager = new DeviceManager(vscode.context, mockGlobalStateManager); + manager = new DeviceManager(vscode.context); (vscode.window as any).state = { focused: true }; const device = createMockDevice(); @@ -996,7 +1016,7 @@ describe('DeviceManager', () => { it('does not clear lastUsedDeviceIp when different device is removed', () => { const clock = sinon.useFakeTimers(); try { - manager = new DeviceManager(vscode.context, mockGlobalStateManager); + manager = new DeviceManager(vscode.context); (vscode.window as any).state = { focused: true }; const device1 = createMockDevice({ serialNumber: 'device-1', ip: '192.168.1.101' }); @@ -1016,22 +1036,26 @@ describe('DeviceManager', () => { it('removes device from lastSeenDevices', () => { const clock = sinon.useFakeTimers(); try { - manager = new DeviceManager(vscode.context, mockGlobalStateManager); + manager = new DeviceManager(vscode.context); (vscode.window as any).state = { focused: true }; const device = createMockDevice(); addDevice(device); + // Add device to lastSeenDevices + setLastSeenDevices('test-network-hash', [device.serialNumber]); + manager['removeDiscoveredDevice'](device.ip); - expect(mockGlobalStateManager.removeLastSeenDevice.calledWith('test-network-hash', device.serialNumber)).to.be.true; + // Verify device was removed from lastSeenDevices + expect(getLastSeenDevices('test-network-hash')).to.not.include(device.serialNumber); } finally { clock.restore(); } }); it('does not throw when removing non-existent device', () => { - manager = new DeviceManager(vscode.context, mockGlobalStateManager); + manager = new DeviceManager(vscode.context); // Should not throw expect(() => manager['removeDiscoveredDevice']('192.168.1.100')).to.not.throw(); @@ -1040,15 +1064,15 @@ describe('DeviceManager', () => { describe('loadLastSeenDevices', () => { it('merges cached devices with existing devices', () => { - manager = new DeviceManager(vscode.context, mockGlobalStateManager); + manager = new DeviceManager(vscode.context); // Add a configured device (configured devices are preserved) const existingDevice = createMockDevice({ serialNumber: 'existing', ip: '192.168.1.150', isConfigured: true }); addDevice(existingDevice); // Setup cache to return a different device - mockGlobalStateManager.getLastSeenDevices.returns(['cached-device']); - mockGlobalStateManager.setCachedDevice('cached-device', { + setLastSeenDevices('test-network-hash', ['cached-device']); + setCachedDevice('cached-device', { serialNumber: 'cached-device', deviceInfo: { 'default-device-name': 'Cached Roku', @@ -1057,14 +1081,7 @@ describe('DeviceManager', () => { createdAt: Date.now() }); // Set IP→serial mapping for cached device (required for loadLastSeenDevices to work) - mockGlobalStateManager.setSerialNumberForIp('test-network-hash', '192.168.1.200', 'cached-device'); - // Mock getIpForSerial to return the IP - mockGlobalStateManager.getIpForSerial = sinon.stub().callsFake((serial) => { - if (serial === 'cached-device') { - return '192.168.1.200'; - } - return undefined; - }); + setSerialNumberForIp('test-network-hash', '192.168.1.200', 'cached-device'); manager['loadLastSeenDevices'](); @@ -1075,10 +1092,10 @@ describe('DeviceManager', () => { }); it('loads cached devices as online when cache is fresh (within 5 minutes)', () => { - manager = new DeviceManager(vscode.context, mockGlobalStateManager); + manager = new DeviceManager(vscode.context); - mockGlobalStateManager.getLastSeenDevices.returns(['device-1']); - mockGlobalStateManager.getCachedDevice.returns({ + setLastSeenDevices('test-network-hash', ['device-1']); + setCachedDevice('device-1', { serialNumber: 'device-1', deviceInfo: { 'default-device-name': 'Test Roku', @@ -1086,8 +1103,8 @@ describe('DeviceManager', () => { }, createdAt: Date.now() // Fresh cache }); - // Mock getIpForSerial to return the IP - mockGlobalStateManager.getIpForSerial = sinon.stub().returns('192.168.1.100'); + // Set IP→serial mapping + setSerialNumberForIp('test-network-hash', '192.168.1.100', 'device-1'); manager['loadLastSeenDevices'](); @@ -1095,10 +1112,10 @@ describe('DeviceManager', () => { }); it('loads cached devices as pending when cache is stale (older than 5 minutes)', () => { - manager = new DeviceManager(vscode.context, mockGlobalStateManager); + manager = new DeviceManager(vscode.context); - mockGlobalStateManager.getLastSeenDevices.returns(['device-1']); - mockGlobalStateManager.getCachedDevice.returns({ + setLastSeenDevices('test-network-hash', ['device-1']); + setCachedDevice('device-1', { serialNumber: 'device-1', deviceInfo: { 'default-device-name': 'Test Roku', @@ -1106,8 +1123,8 @@ describe('DeviceManager', () => { }, createdAt: Date.now() - (6 * 60 * 1_000) // 6 minutes ago - stale }); - // Mock getIpForSerial to return the IP - mockGlobalStateManager.getIpForSerial = sinon.stub().returns('192.168.1.100'); + // Set IP→serial mapping + setSerialNumberForIp('test-network-hash', '192.168.1.100', 'device-1'); manager['loadLastSeenDevices'](); @@ -1115,27 +1132,28 @@ describe('DeviceManager', () => { }); it('removes stale entries when cache returns undefined', () => { - manager = new DeviceManager(vscode.context, mockGlobalStateManager); + manager = new DeviceManager(vscode.context); - mockGlobalStateManager.getLastSeenDevices.returns(['stale-device']); - mockGlobalStateManager.getCachedDevice.returns(undefined); + setLastSeenDevices('test-network-hash', ['stale-device']); + // No cached device - simulates stale entry manager['loadLastSeenDevices'](); expect(manager.getAllDevices().length).to.equal(0); - expect(mockGlobalStateManager.removeLastSeenDevice.calledWith('test-network-hash', 'stale-device')).to.be.true; + // Verify the stale device was removed from lastSeenDevices + expect(getLastSeenDevices('test-network-hash')).to.not.include('stale-device'); }); }); describe('getDevice', () => { it('returns full device with deviceInfo when found', () => { - manager = new DeviceManager(vscode.context, mockGlobalStateManager); + manager = new DeviceManager(vscode.context); const device = createMockDevice({ serialNumber: 'target-device' }); addDevice(device); - // Mock the cache to return deviceInfo - mockGlobalStateManager.getCachedDevice.withArgs('target-device').returns({ + // Set up the cache with deviceInfo + setCachedDevice('target-device', { serialNumber: 'target-device', deviceInfo: { 'serial-number': 'target-device', @@ -1154,7 +1172,7 @@ describe('DeviceManager', () => { }); it('returns undefined when not found', () => { - manager = new DeviceManager(vscode.context, mockGlobalStateManager); + manager = new DeviceManager(vscode.context); const result = manager.getDevice({ serialNumber: 'nonexistent' }); @@ -1176,7 +1194,7 @@ describe('DeviceManager', () => { } } as any); - manager = new DeviceManager(vscode.context, mockGlobalStateManager); + manager = new DeviceManager(vscode.context); // Add device with developer-enabled: false in cached deviceInfo const device = createMockDevice({ @@ -1204,7 +1222,7 @@ describe('DeviceManager', () => { } } as any); - manager = new DeviceManager(vscode.context, mockGlobalStateManager); + manager = new DeviceManager(vscode.context); // Add device without developer-enabled in deviceInfo (unknown status) manager['discoveredDevices'].push({ @@ -1228,7 +1246,7 @@ describe('DeviceManager', () => { } } as any); - manager = new DeviceManager(vscode.context, mockGlobalStateManager); + manager = new DeviceManager(vscode.context); // Add device with developer-enabled in cached deviceInfo const device = createMockDevice({ @@ -1252,7 +1270,7 @@ describe('DeviceManager', () => { } } as any); - manager = new DeviceManager(vscode.context, mockGlobalStateManager); + manager = new DeviceManager(vscode.context); // Add device with developer-enabled: false const device = createMockDevice({ @@ -1276,7 +1294,7 @@ describe('DeviceManager', () => { } } as any); - manager = new DeviceManager(vscode.context, mockGlobalStateManager); + manager = new DeviceManager(vscode.context); // Add device without developer-enabled manager['discoveredDevices'].push({ @@ -1309,7 +1327,7 @@ describe('DeviceManager', () => { } } as any); - manager = new DeviceManager(vscode.context, mockGlobalStateManager); + manager = new DeviceManager(vscode.context); // Add a non-developer device const device = createMockDevice({ @@ -1366,7 +1384,7 @@ describe('DeviceManager', () => { } } as any); - manager = new DeviceManager(vscode.context, mockGlobalStateManager); + manager = new DeviceManager(vscode.context); const showTimedStub = sinon.stub(util, 'showTimedNotification').resolves(); // Add device with cached info @@ -1396,7 +1414,7 @@ describe('DeviceManager', () => { } } as any); - manager = new DeviceManager(vscode.context, mockGlobalStateManager); + manager = new DeviceManager(vscode.context); const showTimedStub = sinon.stub(util, 'showTimedNotification').resolves(); // Trigger device-online without cached device @@ -1417,7 +1435,7 @@ describe('DeviceManager', () => { } } as any); - manager = new DeviceManager(vscode.context, mockGlobalStateManager); + manager = new DeviceManager(vscode.context); const showTimedStub = sinon.stub(util, 'showTimedNotification').resolves(); manager['handleDeviceOnline']('192.168.1.100', 'ABC123'); @@ -1437,7 +1455,7 @@ describe('DeviceManager', () => { } } as any); - manager = new DeviceManager(vscode.context, mockGlobalStateManager); + manager = new DeviceManager(vscode.context); const showTimedStub = sinon.stub(util, 'showTimedNotification').resolves(); // Add device with cached info @@ -1466,7 +1484,7 @@ describe('DeviceManager', () => { describe('fetchDeviceInfo', () => { it('always makes network call (no caching in fetchDeviceInfo)', async () => { - manager = new DeviceManager(vscode.context, mockGlobalStateManager); + manager = new DeviceManager(vscode.context); const getDeviceInfoStub = sinon.stub(rokuDeploy, 'getDeviceInfo').resolves({ 'device-id': 'device-123', @@ -1485,7 +1503,7 @@ describe('DeviceManager', () => { describe('resolveDevice caching', () => { it('only makes one network call for rapid successive requests', async () => { - manager = new DeviceManager(vscode.context, mockGlobalStateManager); + manager = new DeviceManager(vscode.context); const device = createMockDevice(); addDevice(device); @@ -1510,7 +1528,7 @@ describe('DeviceManager', () => { it('makes a new network call after cache TTL expires', async () => { const clock = sinon.useFakeTimers(Date.now()); try { - manager = new DeviceManager(vscode.context, mockGlobalStateManager); + manager = new DeviceManager(vscode.context); const device = createMockDevice(); addDevice(device); @@ -1540,7 +1558,7 @@ describe('DeviceManager', () => { }); it('caches different serial numbers separately', async () => { - manager = new DeviceManager(vscode.context, mockGlobalStateManager); + manager = new DeviceManager(vscode.context); const device1 = createMockDevice({ ip: '192.168.1.100', serialNumber: 'device-100' }); const device2 = createMockDevice({ ip: '192.168.1.101', serialNumber: 'device-101' }); @@ -1585,7 +1603,7 @@ describe('DeviceManager', () => { }); it('refetches on network change when serial unknown (IP→serial mapping is network-specific)', async () => { - manager = new DeviceManager(vscode.context, mockGlobalStateManager); + manager = new DeviceManager(vscode.context); // Device with unknown serial (only IP known) - like a newly discovered device const deviceIpOnly = { ip: '192.168.1.100' }; @@ -1619,7 +1637,7 @@ describe('DeviceManager', () => { }); it('uses cached data on network change when serial is known', async () => { - manager = new DeviceManager(vscode.context, mockGlobalStateManager); + manager = new DeviceManager(vscode.context); // Device with known serial (from config or previous discovery) const device = createMockDevice(); @@ -1659,7 +1677,7 @@ describe('DeviceManager', () => { describe('network change handling', () => { it('updates networkId when NetworkChangeMonitor triggers callback', () => { - manager = new DeviceManager(vscode.context, mockGlobalStateManager); + manager = new DeviceManager(vscode.context); // Change the network hash (NetworkChangeMonitorModule.getNetworkHash as sinon.SinonStub).returns('new-network-hash'); @@ -1672,7 +1690,7 @@ describe('DeviceManager', () => { }); it('reloads devices when network changes', () => { - manager = new DeviceManager(vscode.context, mockGlobalStateManager); + manager = new DeviceManager(vscode.context); // Add a discovered device to verify it gets cleared on network change manager['discoveredDevices'].push({ @@ -1693,7 +1711,7 @@ describe('DeviceManager', () => { }); it('calls setScanNeeded when network changes', () => { - manager = new DeviceManager(vscode.context, mockGlobalStateManager); + manager = new DeviceManager(vscode.context); const setScanNeededSpy = sinon.spy(manager as any, 'setScanNeeded'); @@ -1707,7 +1725,7 @@ describe('DeviceManager', () => { }); it('clears discovered devices when network changes', () => { - manager = new DeviceManager(vscode.context, mockGlobalStateManager); + manager = new DeviceManager(vscode.context); // Add discovered devices manager['discoveredDevices'].push({ @@ -1733,7 +1751,7 @@ describe('DeviceManager', () => { }); it('preserves configured devices when network changes', () => { - manager = new DeviceManager(vscode.context, mockGlobalStateManager); + manager = new DeviceManager(vscode.context); // Add a configured device manager['configuredDevices'].push({ @@ -1767,7 +1785,7 @@ describe('DeviceManager', () => { describe('configured devices', () => { describe('merging configured and discovered', () => { it('merges configured and discovered entries by serialNumber', () => { - manager = new DeviceManager(vscode.context, mockGlobalStateManager); + manager = new DeviceManager(vscode.context); // Add configured device addConfiguredDevice(createMockDevice({ @@ -1792,7 +1810,7 @@ describe('DeviceManager', () => { }); it('merges configured and discovered entries by IP when no serial match', () => { - manager = new DeviceManager(vscode.context, mockGlobalStateManager); + manager = new DeviceManager(vscode.context); // Add configured device (no serial yet - not resolved) manager['configuredDevices'].push({ @@ -1819,10 +1837,10 @@ describe('DeviceManager', () => { }); it('preserves configuredName separately from deviceInfo', () => { - manager = new DeviceManager(vscode.context, mockGlobalStateManager); + manager = new DeviceManager(vscode.context); // Set up cache with deviceInfo - mockGlobalStateManager.getCachedDevice.withArgs('device-123').returns({ + setCachedDevice('device-123', { serialNumber: 'device-123', deviceInfo: { 'user-device-name': 'Discovered Name' }, createdAt: Date.now() @@ -1845,7 +1863,7 @@ describe('DeviceManager', () => { describe('healthCheckDevice with failed network calls', () => { it('marks configured device as offline when health check fails and cache exists', async () => { - manager = new DeviceManager(vscode.context, mockGlobalStateManager); + manager = new DeviceManager(vscode.context); sinon.stub(manager as any, 'randomDelay').resolves(); sinon.stub(manager as any, 'refresh').resolves(); @@ -1856,7 +1874,7 @@ describe('DeviceManager', () => { addDevice(device); // Simulate cache exists - mockGlobalStateManager.getCachedDevice.returns({ + setCachedDevice('device-123', { serialNumber: 'device-123', deviceInfo: { 'serial-number': 'device-123' }, createdAt: Date.now() @@ -1873,7 +1891,7 @@ describe('DeviceManager', () => { }); it('marks configured device as offline when health check fails and no cache exists', async () => { - manager = new DeviceManager(vscode.context, mockGlobalStateManager); + manager = new DeviceManager(vscode.context); sinon.stub(manager as any, 'randomDelay').resolves(); sinon.stub(manager as any, 'refresh').resolves(); @@ -1883,8 +1901,8 @@ describe('DeviceManager', () => { }); addDevice(device); - // Simulate no cache - view layer uses hasDeviceCache() to show warning icon - mockGlobalStateManager.getCachedDevice.returns(undefined); + // Simulate no cache - clear any cache that might have been set + clearDeviceCache(); // Stub to simulate network failure sinon.stub(rokuDeploy, 'getDeviceInfo').rejects(new Error('Device not responding')); @@ -1900,7 +1918,7 @@ describe('DeviceManager', () => { }); it('removes discovered-only device when health check fails', async () => { - manager = new DeviceManager(vscode.context, mockGlobalStateManager); + manager = new DeviceManager(vscode.context); sinon.stub(manager as any, 'randomDelay').resolves(); sinon.stub(manager as any, 'refresh').resolves(); @@ -1922,7 +1940,7 @@ describe('DeviceManager', () => { describe('isDiscovered flag', () => { it('sets isDiscovered true when device comes from discovery', () => { - manager = new DeviceManager(vscode.context, mockGlobalStateManager); + manager = new DeviceManager(vscode.context); // Simulate SSDP discovery - just adds to discoveredDevices manager['setDiscoveredDevice']('192.168.1.100', 'ABC123'); @@ -1932,7 +1950,7 @@ describe('DeviceManager', () => { }); it('sets isDiscovered false when health check fails on configured device', async () => { - manager = new DeviceManager(vscode.context, mockGlobalStateManager); + manager = new DeviceManager(vscode.context); sinon.stub(manager as any, 'randomDelay').resolves(); sinon.stub(manager as any, 'refresh').resolves(); @@ -1955,7 +1973,7 @@ describe('DeviceManager', () => { }); it('removes discovered-only device when health check fails', async () => { - manager = new DeviceManager(vscode.context, mockGlobalStateManager); + manager = new DeviceManager(vscode.context); sinon.stub(manager as any, 'randomDelay').resolves(); sinon.stub(manager as any, 'refresh').resolves(); @@ -1978,7 +1996,7 @@ describe('DeviceManager', () => { describe('getAllDevices sorting', () => { it('sorts by form factor, then name, then serial number', () => { - manager = new DeviceManager(vscode.context, mockGlobalStateManager); + manager = new DeviceManager(vscode.context); // Add TV (priority 2) with Z name addDiscoveredDevice(createMockDevice({ @@ -2024,7 +2042,7 @@ describe('DeviceManager', () => { } }); - manager = new DeviceManager(vscode.context, mockGlobalStateManager); + manager = new DeviceManager(vscode.context); // Add configured device with real device info (was resolved from network) addDevice(createMockDevice({ @@ -2065,7 +2083,7 @@ describe('DeviceManager', () => { } }); - manager = new DeviceManager(vscode.context, mockGlobalStateManager); + manager = new DeviceManager(vscode.context); // Add configured device that was never resolved (no serial) addConfiguredDevice(createMockDevice({ @@ -2102,7 +2120,7 @@ describe('DeviceManager', () => { } }); - manager = new DeviceManager(vscode.context, mockGlobalStateManager); + manager = new DeviceManager(vscode.context); await manager['loadConfiguredDevices'](); @@ -2140,7 +2158,7 @@ describe('DeviceManager', () => { } }); - manager = new DeviceManager(vscode.context, mockGlobalStateManager); + manager = new DeviceManager(vscode.context); sinon.stub(manager as any, 'randomDelay').resolves(); await manager['loadConfiguredDevices'](); @@ -2185,7 +2203,7 @@ describe('DeviceManager', () => { } }); - manager = new DeviceManager(vscode.context, mockGlobalStateManager); + manager = new DeviceManager(vscode.context); await manager['loadConfiguredDevices'](); expect(manager.getAllDevices().length).to.equal(1); @@ -2215,7 +2233,7 @@ describe('DeviceManager', () => { describe('loadLastSeenDevices', () => { it('preserves configured devices and removes discovered-only', () => { - manager = new DeviceManager(vscode.context, mockGlobalStateManager); + manager = new DeviceManager(vscode.context); // Add configured device addConfiguredDevice(createMockDevice({ @@ -2242,7 +2260,7 @@ describe('DeviceManager', () => { describe('resolveDevice', () => { it('preserves isConfigured after successful resolution', async () => { - manager = new DeviceManager(vscode.context, mockGlobalStateManager); + manager = new DeviceManager(vscode.context); const device = createMockDevice({ serialNumber: 'device-123', @@ -2268,7 +2286,7 @@ describe('DeviceManager', () => { describe('clearAllCache', () => { describe('timestamp clearing', () => { it('resets lastScanDate to null', () => { - manager = new DeviceManager(vscode.context, mockGlobalStateManager); + manager = new DeviceManager(vscode.context); // Simulate a scan having occurred manager['lastScanDate'] = new Date(); @@ -2280,7 +2298,7 @@ describe('DeviceManager', () => { }); it('makes timeSinceLastScan return Infinity after clear', () => { - manager = new DeviceManager(vscode.context, mockGlobalStateManager); + manager = new DeviceManager(vscode.context); // Simulate a scan having occurred manager['lastScanDate'] = new Date(); @@ -2292,7 +2310,7 @@ describe('DeviceManager', () => { }); it('clears globalStateManager device cache (enables fresh fetch)', async () => { - manager = new DeviceManager(vscode.context, mockGlobalStateManager); + manager = new DeviceManager(vscode.context); const device = createMockDevice(); addDevice(device); @@ -2326,7 +2344,7 @@ describe('DeviceManager', () => { }); it('clears resolveDeviceSequence map', () => { - manager = new DeviceManager(vscode.context, mockGlobalStateManager); + manager = new DeviceManager(vscode.context); const device = createMockDevice(); @@ -2342,7 +2360,7 @@ describe('DeviceManager', () => { describe('scan state handling', () => { it('calls finder.stop() to handle any in-progress scan', () => { - manager = new DeviceManager(vscode.context, mockGlobalStateManager); + manager = new DeviceManager(vscode.context); const finderStopSpy = sinon.spy(manager['finder'], 'stop'); @@ -2356,7 +2374,7 @@ describe('DeviceManager', () => { it('allows immediate rescan after clear', () => { const clock = sinon.useFakeTimers(); try { - manager = new DeviceManager(vscode.context, mockGlobalStateManager); + manager = new DeviceManager(vscode.context); // Simulate recent scan manager['lastScanDate'] = new Date(); @@ -2378,7 +2396,7 @@ describe('DeviceManager', () => { }); it('health check runs immediately after clear', async () => { - manager = new DeviceManager(vscode.context, mockGlobalStateManager); + manager = new DeviceManager(vscode.context); const device = createMockDevice(); const resolveDeviceSpy = sinon.stub(manager as any, 'resolveDevice').returns(Promise.resolve(true)); @@ -2396,7 +2414,7 @@ describe('DeviceManager', () => { }); it('ignores concurrent health check results after clear', async () => { - manager = new DeviceManager(vscode.context, mockGlobalStateManager); + manager = new DeviceManager(vscode.context); const device = createMockDevice(); @@ -2426,7 +2444,7 @@ describe('DeviceManager', () => { }); it('handles multiple rapid clears safely', () => { - manager = new DeviceManager(vscode.context, mockGlobalStateManager); + manager = new DeviceManager(vscode.context); manager['lastScanDate'] = new Date(); @@ -2441,25 +2459,29 @@ describe('DeviceManager', () => { }); }); - describe('integration with globalStateManager', () => { - it('calls globalStateManager.clearLastSeenDevices', () => { - manager = new DeviceManager(vscode.context, mockGlobalStateManager); + describe('integration with deviceStorage', () => { + it('clears lastSeenDevices', () => { + manager = new DeviceManager(vscode.context); - mockGlobalStateManager.clearLastSeenDevices = sinon.stub(); + // Set up some data first + setLastSeenDevices('test-network-hash', ['device-1', 'device-2']); manager.clearAllCache(); - expect(mockGlobalStateManager.clearLastSeenDevices.calledOnce).to.be.true; + // Verify cleared + expect(globalStateData()['lastSeenDevicesByNetwork']).to.be.undefined; }); - it('calls globalStateManager.clearDeviceCache', () => { - manager = new DeviceManager(vscode.context, mockGlobalStateManager); + it('clears deviceCache', () => { + manager = new DeviceManager(vscode.context); - mockGlobalStateManager.clearDeviceCache = sinon.stub(); + // Set up some data first + setCachedDevice('device-1', { serialNumber: 'device-1', deviceInfo: {}, createdAt: Date.now() }); manager.clearAllCache(); - expect(mockGlobalStateManager.clearDeviceCache.calledOnce).to.be.true; + // Verify cleared + expect(globalStateData()['deviceCache']).to.be.undefined; }); }); }); @@ -2468,7 +2490,7 @@ describe('DeviceManager', () => { describe('device key encoding/decoding', () => { describe('key encoding', () => { it('uses serial-based key (s:...) when serial exists', () => { - manager = new DeviceManager(vscode.context, mockGlobalStateManager); + manager = new DeviceManager(vscode.context); const device = createMockDevice({ serialNumber: 'ABC123', @@ -2483,7 +2505,7 @@ describe('DeviceManager', () => { }); it('uses IP-based key (i:...) when no serial exists', () => { - manager = new DeviceManager(vscode.context, mockGlobalStateManager); + manager = new DeviceManager(vscode.context); // Create device without serial - manually add to discovered array manager['discoveredDevices'].push({ @@ -2498,7 +2520,7 @@ describe('DeviceManager', () => { }); it('includes key in getAllDevices() results', () => { - manager = new DeviceManager(vscode.context, mockGlobalStateManager); + manager = new DeviceManager(vscode.context); const device = createMockDevice({ serialNumber: 'DEF456', @@ -2515,7 +2537,7 @@ describe('DeviceManager', () => { describe('key decoding/lookup', () => { it('getDevice("s:ABC123") finds device by serial', () => { - manager = new DeviceManager(vscode.context, mockGlobalStateManager); + manager = new DeviceManager(vscode.context); const device = createMockDevice({ serialNumber: 'ABC123', @@ -2532,7 +2554,7 @@ describe('DeviceManager', () => { }); it('getDevice("i:192.168.1.100") finds device by IP', () => { - manager = new DeviceManager(vscode.context, mockGlobalStateManager); + manager = new DeviceManager(vscode.context); const device = createMockDevice({ serialNumber: 'XYZ789', @@ -2549,7 +2571,7 @@ describe('DeviceManager', () => { }); it('IP-based lookup still works after device gains serial (stale key)', () => { - manager = new DeviceManager(vscode.context, mockGlobalStateManager); + manager = new DeviceManager(vscode.context); // Device initially added by IP, then gains serial const device = createMockDevice({ @@ -2571,7 +2593,7 @@ describe('DeviceManager', () => { describe('edge cases', () => { it('returns undefined for unprefixed string (rejects invalid format)', () => { - manager = new DeviceManager(vscode.context, mockGlobalStateManager); + manager = new DeviceManager(vscode.context); const device = createMockDevice({ serialNumber: 'ABC123', @@ -2586,7 +2608,7 @@ describe('DeviceManager', () => { }); it('returns undefined for empty key after prefix', () => { - manager = new DeviceManager(vscode.context, mockGlobalStateManager); + manager = new DeviceManager(vscode.context); const device = createMockDevice({ serialNumber: 'ABC123', @@ -2599,7 +2621,7 @@ describe('DeviceManager', () => { }); it('returns undefined for empty string', () => { - manager = new DeviceManager(vscode.context, mockGlobalStateManager); + manager = new DeviceManager(vscode.context); const result = manager.getDevice(''); @@ -2607,7 +2629,7 @@ describe('DeviceManager', () => { }); it('returns undefined for unknown serial key', () => { - manager = new DeviceManager(vscode.context, mockGlobalStateManager); + manager = new DeviceManager(vscode.context); const device = createMockDevice({ serialNumber: 'ABC123', @@ -2621,7 +2643,7 @@ describe('DeviceManager', () => { }); it('returns undefined for unknown IP key', () => { - manager = new DeviceManager(vscode.context, mockGlobalStateManager); + manager = new DeviceManager(vscode.context); const device = createMockDevice({ serialNumber: 'ABC123', @@ -2637,7 +2659,7 @@ describe('DeviceManager', () => { describe('key transition', () => { it('device key changes from IP-based to serial-based when re-set with serial', () => { - manager = new DeviceManager(vscode.context, mockGlobalStateManager); + manager = new DeviceManager(vscode.context); // Start with device that has no serial manager['discoveredDevices'].push({ @@ -2665,7 +2687,7 @@ describe('DeviceManager', () => { describe('serial-based deduplication (DHCP IP change)', () => { describe('setDiscoveredDevice', () => { it('removes old entry when same serial discovered at new IP', () => { - manager = new DeviceManager(vscode.context, mockGlobalStateManager); + manager = new DeviceManager(vscode.context); // Device exists at old IP const oldDevice = createMockDevice({ @@ -2686,7 +2708,7 @@ describe('DeviceManager', () => { }); it('preserves configured properties when device changes IP', () => { - manager = new DeviceManager(vscode.context, mockGlobalStateManager); + manager = new DeviceManager(vscode.context); // Configured device exists at old IP const oldDevice = createMockDevice({ @@ -2712,7 +2734,7 @@ describe('DeviceManager', () => { }); it('transfers lastUsedDeviceIp when device changes IP', () => { - manager = new DeviceManager(vscode.context, mockGlobalStateManager); + manager = new DeviceManager(vscode.context); // Device exists at old IP and is the last used device const oldDevice = createMockDevice({ @@ -2734,7 +2756,7 @@ describe('DeviceManager', () => { describe('resolveDevice', () => { it('removes old entry when same serial resolved at new IP', async () => { - manager = new DeviceManager(vscode.context, mockGlobalStateManager); + manager = new DeviceManager(vscode.context); sinon.stub(manager as any, 'randomDelay').resolves(); // Device exists at old IP @@ -2773,7 +2795,7 @@ describe('DeviceManager', () => { }); it('preserves configured properties when resolving at new IP', async () => { - manager = new DeviceManager(vscode.context, mockGlobalStateManager); + manager = new DeviceManager(vscode.context); sinon.stub(manager as any, 'randomDelay').resolves(); // Configured device exists at old IP @@ -2818,7 +2840,7 @@ describe('DeviceManager', () => { describe('same serial configured at multiple IPs', () => { it('collapses to single entry when resolved', async () => { - manager = new DeviceManager(vscode.context, mockGlobalStateManager); + manager = new DeviceManager(vscode.context); sinon.stub(manager as any, 'randomDelay').resolves(); // Two configured entries for same serial at different IPs @@ -2859,7 +2881,7 @@ describe('DeviceManager', () => { describe('cross-state preservation', () => { it('keeps discovered IP when config has stale IP for same serial', () => { - manager = new DeviceManager(vscode.context, mockGlobalStateManager); + manager = new DeviceManager(vscode.context); // Device discovered at IP1 (real network location) addDiscoveredDevice(createMockDevice({ @@ -2888,7 +2910,7 @@ describe('DeviceManager', () => { }); it('preserves isConfigured when configured device gets discovered at new IP', () => { - manager = new DeviceManager(vscode.context, mockGlobalStateManager); + manager = new DeviceManager(vscode.context); // Configured-only device at old IP (not yet discovered on network) const oldDevice = createMockDevice({ @@ -2917,7 +2939,7 @@ describe('DeviceManager', () => { describe('edge cases', () => { it('does not dedupe when serial is undefined', () => { - manager = new DeviceManager(vscode.context, mockGlobalStateManager); + manager = new DeviceManager(vscode.context); // Device without serial at old IP const oldDevice = createMockDevice({ @@ -2936,7 +2958,7 @@ describe('DeviceManager', () => { }); it('does not remove device at same IP (not a duplicate)', () => { - manager = new DeviceManager(vscode.context, mockGlobalStateManager); + manager = new DeviceManager(vscode.context); // Device exists with fresh cache (deviceInfo provided populates cache) const device = createMockDevice({ @@ -2963,39 +2985,39 @@ describe('DeviceManager', () => { describe('serial mismatch detection', () => { describe('checkForSerialMismatch', () => { it('returns false when no new serial is provided', () => { - manager = new DeviceManager(vscode.context, mockGlobalStateManager); + manager = new DeviceManager(vscode.context); const result = manager['checkForSerialMismatch']('192.168.1.100', undefined); expect(result).to.be.false; }); it('returns false when no stored serial exists for IP', () => { - manager = new DeviceManager(vscode.context, mockGlobalStateManager); - mockGlobalStateManager.getSerialNumberForIp.returns(undefined); + manager = new DeviceManager(vscode.context); + // No serial mapping set up - default state const result = manager['checkForSerialMismatch']('192.168.1.100', 'NEW-SERIAL'); expect(result).to.be.false; }); it('returns false when stored serial matches new serial', () => { - manager = new DeviceManager(vscode.context, mockGlobalStateManager); - mockGlobalStateManager.getSerialNumberForIp.returns('ABC123'); + manager = new DeviceManager(vscode.context); + setSerialNumberForIp('test-network-hash', '192.168.1.100', 'ABC123'); const result = manager['checkForSerialMismatch']('192.168.1.100', 'ABC123'); expect(result).to.be.false; }); it('returns true when stored serial differs from new serial', () => { - manager = new DeviceManager(vscode.context, mockGlobalStateManager); - mockGlobalStateManager.getSerialNumberForIp.returns('OLD-SERIAL'); + manager = new DeviceManager(vscode.context); + setSerialNumberForIp('test-network-hash', '192.168.1.100', 'OLD-SERIAL'); const result = manager['checkForSerialMismatch']('192.168.1.100', 'NEW-SERIAL'); expect(result).to.be.true; }); it('returns false when configured device has different serial (avoids reload loop)', () => { - manager = new DeviceManager(vscode.context, mockGlobalStateManager); - mockGlobalStateManager.getSerialNumberForIp.returns(undefined); + manager = new DeviceManager(vscode.context); + // No serial mapping set up // Add configured device with serial - this is a user misconfiguration // We intentionally don't trigger mismatch here because reloading @@ -3011,8 +3033,8 @@ describe('DeviceManager', () => { }); it('returns true when discovered device has different serial', () => { - manager = new DeviceManager(vscode.context, mockGlobalStateManager); - mockGlobalStateManager.getSerialNumberForIp.returns(undefined); + manager = new DeviceManager(vscode.context); + // No serial mapping set up // Add discovered device with serial manager['discoveredDevices'].push({ @@ -3027,11 +3049,11 @@ describe('DeviceManager', () => { describe('config reload on mismatch', () => { it('reloads configured devices when resolveDevice detects serial mismatch', async () => { - manager = new DeviceManager(vscode.context, mockGlobalStateManager); + manager = new DeviceManager(vscode.context); sinon.stub(manager as any, 'randomDelay').resolves(); // Set up: stored serial for this IP - mockGlobalStateManager.getSerialNumberForIp.returns('OLD-SERIAL'); + setSerialNumberForIp('test-network-hash', '192.168.1.100', 'OLD-SERIAL'); // Spy on loadConfiguredDevices const loadConfigSpy = sinon.spy(manager as any, 'loadConfiguredDevices'); @@ -3059,10 +3081,10 @@ describe('DeviceManager', () => { }); it('reloads configured devices when SSDP finds device with different serial at known IP', () => { - manager = new DeviceManager(vscode.context, mockGlobalStateManager); + manager = new DeviceManager(vscode.context); // Set up: stored serial for this IP - mockGlobalStateManager.getSerialNumberForIp.returns('OLD-SERIAL'); + setSerialNumberForIp('test-network-hash', '192.168.1.100', 'OLD-SERIAL'); // Spy on loadConfiguredDevices const loadConfigSpy = sinon.spy(manager as any, 'loadConfiguredDevices'); @@ -3075,11 +3097,11 @@ describe('DeviceManager', () => { }); it('does not reload when serial matches', async () => { - manager = new DeviceManager(vscode.context, mockGlobalStateManager); + manager = new DeviceManager(vscode.context); sinon.stub(manager as any, 'randomDelay').resolves(); // Set up: stored serial for this IP - mockGlobalStateManager.getSerialNumberForIp.returns('SAME-SERIAL'); + setSerialNumberForIp('test-network-hash', '192.168.1.100', 'SAME-SERIAL'); // Spy on loadConfiguredDevices const loadConfigSpy = sinon.spy(manager as any, 'loadConfiguredDevices'); @@ -3100,21 +3122,13 @@ describe('DeviceManager', () => { }); describe('configured device with mismatched serial at IP', () => { - let ipToSerialMap: Map; - beforeEach(() => { - // Reset the IP→serial tracking map and restore callsFake behavior - ipToSerialMap = new Map(); - mockGlobalStateManager.getSerialNumberForIp.callsFake((ip: string, networkId: string) => { - return ipToSerialMap.get(`${networkId}:${ip}`); - }); - mockGlobalStateManager.setSerialNumberForIp.callsFake((networkId: string, ip: string, serial: string) => { - ipToSerialMap.set(`${networkId}:${ip}`, serial); - }); + // Clear serial mapping for each test + clearSerialNumberByIpForNetwork(); }); it('shows two devices when configured serial differs from device at IP', async () => { - manager = new DeviceManager(vscode.context, mockGlobalStateManager); + manager = new DeviceManager(vscode.context); sinon.stub(manager as any, 'randomDelay').resolves(); // User configured device with serial ABC at this IP @@ -3188,26 +3202,26 @@ describe('DeviceManager', () => { it('returns undefined when setting is missing', () => { stubConfig(undefined); - manager = new DeviceManager(vscode.context, mockGlobalStateManager); + manager = new DeviceManager(vscode.context); expect(manager.getDefaultPassword()).to.be.undefined; }); it('returns undefined when setting is an empty string', () => { stubConfig(''); - manager = new DeviceManager(vscode.context, mockGlobalStateManager); + manager = new DeviceManager(vscode.context); expect(manager.getDefaultPassword()).to.be.undefined; }); it('returns the configured value when setting is a non-empty string', () => { stubConfig('hunter2'); - manager = new DeviceManager(vscode.context, mockGlobalStateManager); + manager = new DeviceManager(vscode.context); expect(manager.getDefaultPassword()).to.equal('hunter2'); }); describe('getDevice fallback', () => { it('applies defaultPassword to a device missing configuredPassword', () => { stubConfig('hunter2'); - manager = new DeviceManager(vscode.context, mockGlobalStateManager); + manager = new DeviceManager(vscode.context); manager['discoveredDevices'].push({ serialNumber: 'abc', @@ -3221,7 +3235,7 @@ describe('DeviceManager', () => { it('preserves a device-specific configuredPassword over defaultPassword', () => { stubConfig('hunter2'); - manager = new DeviceManager(vscode.context, mockGlobalStateManager); + manager = new DeviceManager(vscode.context); // Add a configured device with a specific password manager['configuredDevices'].push({ @@ -3236,7 +3250,7 @@ describe('DeviceManager', () => { it('leaves configuredPassword undefined when no default and no per-device password', () => { stubConfig(undefined); - manager = new DeviceManager(vscode.context, mockGlobalStateManager); + manager = new DeviceManager(vscode.context); manager['discoveredDevices'].push({ serialNumber: 'abc', @@ -3252,7 +3266,7 @@ describe('DeviceManager', () => { describe('getAllDevices fallback', () => { it('applies defaultPassword to every device missing a per-device password', () => { stubConfig('hunter2'); - manager = new DeviceManager(vscode.context, mockGlobalStateManager); + manager = new DeviceManager(vscode.context); // Discovered device without password manager['discoveredDevices'].push({ @@ -3277,7 +3291,7 @@ describe('DeviceManager', () => { it('does not mutate the underlying device entry when applying the fallback', () => { stubConfig('hunter2'); - manager = new DeviceManager(vscode.context, mockGlobalStateManager); + manager = new DeviceManager(vscode.context); manager['discoveredDevices'].push({ serialNumber: 'abc', diff --git a/src/deviceDiscovery/DeviceManager.ts b/src/deviceDiscovery/DeviceManager.ts index f58e8d89..47d90814 100644 --- a/src/deviceDiscovery/DeviceManager.ts +++ b/src/deviceDiscovery/DeviceManager.ts @@ -4,21 +4,21 @@ import { firstBy } from 'thenby'; import type { Disposable } from 'vscode'; import { rokuDeploy, DeviceUnreachableError, type DeviceInfoRaw } from 'roku-deploy'; import { util as rokuDebugUtil } from 'roku-debug/dist/util'; -import type { GlobalStateManager } from '../GlobalStateManager'; import { RokuFinder } from './RokuFinder'; import { NetworkChangeMonitor, getNetworkHash } from './NetworkChangeMonitor'; import { SystemSleepMonitor } from './SystemSleepMonitor'; +import { DeviceStorageManager, type HeartbeatProvider } from './DeviceStorageManager'; import { util } from '../util'; import { vscodeContextManager } from '../managers/VscodeContextManager'; import { debounce } from 'lodash'; -export class DeviceManager { +export class DeviceManager implements HeartbeatProvider { // #region constructor constructor( private context: vscode.ExtensionContext, - private globalStateManager: GlobalStateManager, private extensionOutputChannel?: vscode.OutputChannel ) { + this.deviceStorage = new DeviceStorageManager(context); this.networkId = getNetworkHash(); this.setupConfiguration(); @@ -28,6 +28,17 @@ export class DeviceManager { this.context.subscriptions.push(this); } + private deviceStorage: DeviceStorageManager; + + // HeartbeatProvider implementation - pass through to deviceStorage + public getLastAliveTimestamp(key: string): number | undefined { + return this.deviceStorage.getLastAliveTimestamp(key); + } + + public setLastAliveTimestamp(key: string, timestamp: number): void { + this.deviceStorage.setLastAliveTimestamp(key, timestamp); + } + private setupConfiguration() { const applyConfig = (event?: vscode.ConfigurationChangeEvent) => { let config: any = util.getConfiguration('brightscript') || {}; @@ -111,7 +122,7 @@ export class DeviceManager { private initialize() { //clear any deviceInfo entries older than our max age - this.globalStateManager.clearExpiredDevices(); + this.deviceStorage.clearExpiredDevices(); // Load configured devices and cached devices (order doesn't matter due to setDevice merge logic) this.loadConfiguredDevices().catch(() => { }); @@ -125,7 +136,7 @@ export class DeviceManager { this.systemSleepMonitor.start(); this.activateMonitoring().then(() => { - const lastSeenDeviceIds = this.globalStateManager.getLastSeenDevices(this.networkId); + const lastSeenDeviceIds = this.deviceStorage.getLastSeenDevices(this.networkId); if (lastSeenDeviceIds.length === 0) { this.refresh(); } else { @@ -149,7 +160,7 @@ export class DeviceManager { private emitter = new EventEmitter(); private systemSleepMonitor: SystemSleepMonitor; private networkChangeMonitor: NetworkChangeMonitor; - private finder = new RokuFinder(this.globalStateManager, this.makeFinderLogger()); + private finder = new RokuFinder(this, this.makeFinderLogger()); // Health check tracking and cooldowns private resolveDeviceSequence = new Map(); @@ -394,7 +405,7 @@ export class DeviceManager { resolvedState = 'online'; } else if (lookup.serialNumber) { // Check cache freshness to determine state - const cached = this.globalStateManager.getCachedDevice(lookup.serialNumber); + const cached = this.deviceStorage.getCachedDevice(lookup.serialNumber); if (cached && now - cached.createdAt < this.FRESH_CACHE_THRESHOLD_MS) { resolvedState = 'online'; } else { @@ -425,7 +436,7 @@ export class DeviceManager { * Used by view providers to determine icon: warning (no cache) vs disconnect (has cache). */ public hasDeviceCache(serialNumber: string): boolean { - return !!this.globalStateManager.getCachedDevice(serialNumber); + return !!this.deviceStorage.getCachedDevice(serialNumber); } /** @@ -472,19 +483,26 @@ export class DeviceManager { } //clear the cache for the current list of devices - this.globalStateManager.setLastSeenDevices(this.networkId, []); + this.deviceStorage.setLastSeenDevices(this.networkId, []); this.emitDevicesChanged(); } + /** + * Clear just the last seen devices (without clearing the full device cache) + */ + public clearLastSeenDevices(): void { + this.deviceStorage.clearLastSeenDevices(); + } + public clearAllCache() { // Stop any in-progress scan (finder.stop() emits scan-ended if scanning) this.finder.stop(); // Clear persisted global state - this.globalStateManager.clearLastSeenDevices(); - this.globalStateManager.clearDeviceCache(); - this.globalStateManager.clearSerialNumberByIpForNetwork(); + this.deviceStorage.clearLastSeenDevices(); + this.deviceStorage.clearDeviceCache(); + this.deviceStorage.clearSerialNumberByIpForNetwork(); // Clear all timestamps and per-device state this.lastScanDate = null; @@ -632,22 +650,22 @@ export class DeviceManager { this.discoveredDevices = []; // Load cached devices for current network - add to discoveredDevices with 'pending' state - const lastSeenDevices = this.globalStateManager.getLastSeenDevices(this.networkId); + const lastSeenDevices = this.deviceStorage.getLastSeenDevices(this.networkId); for (const serialNumber of lastSeenDevices) { - const cached = this.globalStateManager.getCachedDevice(serialNumber); + const cached = this.deviceStorage.getCachedDevice(serialNumber); if (cached && typeof cached === 'object' && !Array.isArray(cached)) { // Get IP from ip-to-serial mapping - const ip = this.globalStateManager.getIpForSerial(serialNumber, this.networkId); + const ip = this.deviceStorage.getIpForSerial(serialNumber, this.networkId); if (!ip) { // No IP mapping found - remove stale entry - this.globalStateManager.removeLastSeenDevice(this.networkId, serialNumber); + this.deviceStorage.removeLastSeenDevice(this.networkId, serialNumber); continue; } // Add to discoveredDevices array (state determined from cache freshness) this.setDiscoveredDevice(ip, serialNumber); } else { // No cached info - remove stale entry - this.globalStateManager.removeLastSeenDevice(this.networkId, serialNumber); + this.deviceStorage.removeLastSeenDevice(this.networkId, serialNumber); } } } @@ -748,8 +766,8 @@ export class DeviceManager { let deviceInfo: DeviceInfoRaw | undefined; // Try to find cached data via serial number - const serialForCache = knownSerial ?? this.globalStateManager.getSerialNumberForIp(device.ip, this.networkId); - const cached = serialForCache ? this.globalStateManager.getCachedDevice(serialForCache) : undefined; + const serialForCache = knownSerial ?? this.deviceStorage.getSerialNumberForIp(device.ip, this.networkId); + const cached = serialForCache ? this.deviceStorage.getCachedDevice(serialForCache) : undefined; const cacheIsFresh = cached && (Date.now() - cached.createdAt < this.DEVICE_INFO_CACHE_MS); // Use cache only if: @@ -791,7 +809,7 @@ export class DeviceManager { if (serial) { // Add to last seen devices (successfully resolved with serial) - this.globalStateManager.addLastSeenDevice(this.networkId, serial); + this.deviceStorage.addLastSeenDevice(this.networkId, serial); } // Update discoveredDevices array (handles mismatch detection internally) @@ -843,7 +861,7 @@ export class DeviceManager { } // Check what serial we have stored for this IP in the IP→serial map - const storedSerial = this.globalStateManager.getSerialNumberForIp(ip, this.networkId); + const storedSerial = this.deviceStorage.getSerialNumberForIp(ip, this.networkId); if (storedSerial && storedSerial !== newSerial) { // Different device is now at this IP @@ -921,7 +939,7 @@ export class DeviceManager { // No serial = no cache, consider stale return true; } - const cached = this.globalStateManager.getCachedDevice(device.serialNumber); + const cached = this.deviceStorage.getCachedDevice(device.serialNumber); if (!cached) { return true; } @@ -949,12 +967,12 @@ export class DeviceManager { timeout: DeviceManager.HEALTH_CHECK_TIMEOUT_MS }); if (info['serial-number']) { - this.globalStateManager.setCachedDevice(info['serial-number'], { + this.deviceStorage.setCachedDevice(info['serial-number'], { serialNumber: info['serial-number'], deviceInfo: info, createdAt: Date.now() }); - this.globalStateManager.setSerialNumberForIp(this.networkId, ip, info['serial-number']); + this.deviceStorage.setSerialNumberForIp(this.networkId, ip, info['serial-number']); } return info; @@ -1048,7 +1066,7 @@ export class DeviceManager { // Remove from lastSeenDevices if we have a serial if (device?.serialNumber) { - this.globalStateManager.removeLastSeenDevice(this.networkId, device.serialNumber); + this.deviceStorage.removeLastSeenDevice(this.networkId, device.serialNumber); } } @@ -1129,7 +1147,7 @@ export class DeviceManager { // cache is fallback for initial load before discovery runs const serialNumber = configuredEntry?.serialNumber ?? discoveredEntry?.serialNumber ?? - this.globalStateManager.getSerialNumberForIp(ip, this.networkId); + this.deviceStorage.getSerialNumberForIp(ip, this.networkId); const deviceState = this.getDeviceState({ serialNumber: serialNumber, ip: ip }).state; @@ -1137,7 +1155,7 @@ export class DeviceManager { const key = serialNumber ? `s:${serialNumber}` : `i:${ip}`; // Hydrate deviceInfo from cache - const cached = serialNumber ? this.globalStateManager.getCachedDevice(serialNumber) : undefined; + const cached = serialNumber ? this.deviceStorage.getCachedDevice(serialNumber) : undefined; return { ip: ip, @@ -1163,11 +1181,11 @@ export class DeviceManager { } // Get actual serial number from IP→serial mapping (more reliable than SSDP hint) - const actualSerial = this.globalStateManager.getSerialNumberForIp(ip, this.networkId) ?? serialNumber; + const actualSerial = this.deviceStorage.getSerialNumberForIp(ip, this.networkId) ?? serialNumber; // Get cached device directly from globalStateManager const cachedDevice = actualSerial - ? this.globalStateManager.getCachedDevice(actualSerial) + ? this.deviceStorage.getCachedDevice(actualSerial) : undefined; // Get display name from cache @@ -1236,7 +1254,7 @@ export class DeviceManager { const oldFinder = this.finder; // Create new finder instance - this.finder = new RokuFinder(this.globalStateManager, this.makeFinderLogger()); + this.finder = new RokuFinder(this, this.makeFinderLogger()); // Re-attach event listeners this.setupFinderListeners(); diff --git a/src/deviceDiscovery/RokuFinder.spec.ts b/src/deviceDiscovery/RokuFinder.spec.ts index 25864621..f140dfd2 100644 --- a/src/deviceDiscovery/RokuFinder.spec.ts +++ b/src/deviceDiscovery/RokuFinder.spec.ts @@ -1,18 +1,18 @@ import { expect } from 'chai'; import * as sinon from 'sinon'; import { RokuFinder } from './RokuFinder'; -import type { GlobalStateManager } from '../GlobalStateManager'; +import type { HeartbeatProvider } from './DeviceStorageManager'; describe('RokuFinder', () => { let finder: RokuFinder; - let mockGlobalStateManager: GlobalStateManager; + let mockHeartbeatProvider: HeartbeatProvider; beforeEach(() => { const timestampStore = new Map(); - mockGlobalStateManager = { + mockHeartbeatProvider = { getLastAliveTimestamp: (key: string) => timestampStore.get(key), - setLastAliveTimestamp: (key: string, ts: number) => timestampStore.set(key, ts) - } as any; + setLastAliveTimestamp: (key: string, ts: number) => { timestampStore.set(key, ts); } + }; }); afterEach(() => { @@ -24,34 +24,34 @@ describe('RokuFinder', () => { describe('constructor', () => { it('creates without error', () => { expect(() => { - finder = new RokuFinder(mockGlobalStateManager); + finder = new RokuFinder(mockHeartbeatProvider); }).to.not.throw(); }); }); describe('start/stop', () => { it('start sets running to true', async () => { - finder = new RokuFinder(mockGlobalStateManager); + finder = new RokuFinder(mockHeartbeatProvider); await finder.start(); expect(finder['running']).to.be.true; }); it('stop sets running to false', async () => { - finder = new RokuFinder(mockGlobalStateManager); + finder = new RokuFinder(mockHeartbeatProvider); await finder.start(); finder.stop(); expect(finder['running']).to.be.false; }); it('start is idempotent', async () => { - finder = new RokuFinder(mockGlobalStateManager); + finder = new RokuFinder(mockHeartbeatProvider); await finder.start(); await finder.start(); expect(finder['running']).to.be.true; }); it('stop is idempotent', async () => { - finder = new RokuFinder(mockGlobalStateManager); + finder = new RokuFinder(mockHeartbeatProvider); await finder.start(); finder.stop(); expect(() => finder.stop()).to.not.throw(); @@ -62,7 +62,7 @@ describe('RokuFinder', () => { it('sends multiple search requests', () => { const clock = sinon.useFakeTimers(); try { - finder = new RokuFinder(mockGlobalStateManager); + finder = new RokuFinder(mockHeartbeatProvider); const searchStub = sinon.stub(finder['client'], 'search'); finder.scan(); @@ -86,7 +86,7 @@ describe('RokuFinder', () => { describe('SSDP response handling', () => { it('emits "found" with IP string for Roku devices', () => { - finder = new RokuFinder(mockGlobalStateManager); + finder = new RokuFinder(mockHeartbeatProvider); const foundSpy = sinon.spy(); finder.on('found', foundSpy); @@ -106,7 +106,7 @@ describe('RokuFinder', () => { }); it('extracts serial number from USN header', () => { - finder = new RokuFinder(mockGlobalStateManager); + finder = new RokuFinder(mockHeartbeatProvider); const foundSpy = sinon.spy(); finder.on('found', foundSpy); @@ -121,7 +121,7 @@ describe('RokuFinder', () => { }); it('handles missing USN gracefully', () => { - finder = new RokuFinder(mockGlobalStateManager); + finder = new RokuFinder(mockHeartbeatProvider); const foundSpy = sinon.spy(); finder.on('found', foundSpy); @@ -137,7 +137,7 @@ describe('RokuFinder', () => { }); it('ignores non-Roku devices', () => { - finder = new RokuFinder(mockGlobalStateManager); + finder = new RokuFinder(mockHeartbeatProvider); const foundSpy = sinon.spy(); finder.on('found', foundSpy); @@ -152,7 +152,7 @@ describe('RokuFinder', () => { }); it('processes scan responses even when passive listener not started', () => { - finder = new RokuFinder(mockGlobalStateManager); + finder = new RokuFinder(mockHeartbeatProvider); // Don't call start() - passive listener is off, but scans should still work const foundSpy = sinon.spy(); @@ -178,7 +178,7 @@ describe('RokuFinder', () => { }); it('emits "found" with IP string and serial number on ssdp:alive', async () => { - finder = new RokuFinder(mockGlobalStateManager); + finder = new RokuFinder(mockHeartbeatProvider); await finder.start(); const foundSpy = sinon.spy(); @@ -199,7 +199,7 @@ describe('RokuFinder', () => { }); it('emits "device-online" with IP and serial number the first time a device is seen', async () => { - finder = new RokuFinder(mockGlobalStateManager); + finder = new RokuFinder(mockHeartbeatProvider); await finder.start(); const deviceOnlineSpy = sinon.spy(); @@ -220,7 +220,7 @@ describe('RokuFinder', () => { it('suppresses "device-online" for a routine ~20-minute heartbeat', () => { const clock = sinon.useFakeTimers(); try { - finder = new RokuFinder(mockGlobalStateManager); + finder = new RokuFinder(mockHeartbeatProvider); finder['running'] = true; const deviceOnlineSpy = sinon.spy(); @@ -249,7 +249,7 @@ describe('RokuFinder', () => { it('suppresses "device-online" when alive arrives within ±10s of 20-minute schedule', () => { const clock = sinon.useFakeTimers(); try { - finder = new RokuFinder(mockGlobalStateManager); + finder = new RokuFinder(mockHeartbeatProvider); finder['running'] = true; const deviceOnlineSpy = sinon.spy(); @@ -283,7 +283,7 @@ describe('RokuFinder', () => { it('suppresses "device-online" when alive arrives at an exact multiple of 20 minutes (e.g. skipped heartbeat)', () => { const clock = sinon.useFakeTimers(); try { - finder = new RokuFinder(mockGlobalStateManager); + finder = new RokuFinder(mockHeartbeatProvider); finder['running'] = true; const deviceOnlineSpy = sinon.spy(); @@ -317,7 +317,7 @@ describe('RokuFinder', () => { it('suppresses after waking from 12-hour sleep (36 missed heartbeats)', () => { const clock = sinon.useFakeTimers(); try { - finder = new RokuFinder(mockGlobalStateManager); + finder = new RokuFinder(mockHeartbeatProvider); finder['running'] = true; const deviceOnlineSpy = sinon.spy(); @@ -356,7 +356,7 @@ describe('RokuFinder', () => { realWorldElapsedMs.forEach((elapsedMs) => { it(`suppresses at real-world elapsed ${(elapsedMs / 1000).toFixed(1)}s (${(elapsedMs / (1198.86 * 1000)).toFixed(0)}× interval)`, () => { - const localFinder = new RokuFinder(mockGlobalStateManager); + const localFinder = new RokuFinder(mockHeartbeatProvider); const clock = sinon.useFakeTimers(); try { localFinder['running'] = true; @@ -386,7 +386,7 @@ describe('RokuFinder', () => { it('emits "device-online" again when alive arrives off the 20-minute schedule (e.g. reboot)', () => { const clock = sinon.useFakeTimers(); try { - finder = new RokuFinder(mockGlobalStateManager); + finder = new RokuFinder(mockHeartbeatProvider); finder['running'] = true; const deviceOnlineSpy = sinon.spy(); @@ -415,7 +415,7 @@ describe('RokuFinder', () => { it('suppresses after a 3-day gap if the Roku fires on its regular schedule (host slept, Roku did not reboot)', () => { const clock = sinon.useFakeTimers(); try { - finder = new RokuFinder(mockGlobalStateManager); + finder = new RokuFinder(mockHeartbeatProvider); finder['running'] = true; const deviceOnlineSpy = sinon.spy(); @@ -443,7 +443,7 @@ describe('RokuFinder', () => { it('fires device-online after a 3-day gap if the Roku rebooted (alive arrives off schedule)', () => { const clock = sinon.useFakeTimers(); try { - finder = new RokuFinder(mockGlobalStateManager); + finder = new RokuFinder(mockHeartbeatProvider); finder['running'] = true; const deviceOnlineSpy = sinon.spy(); @@ -471,7 +471,7 @@ describe('RokuFinder', () => { it('suppresses the next routine heartbeat after a reboot (clock resets from the reboot alive)', () => { const clock = sinon.useFakeTimers(); try { - finder = new RokuFinder(mockGlobalStateManager); + finder = new RokuFinder(mockHeartbeatProvider); finder['running'] = true; const deviceOnlineSpy = sinon.spy(); @@ -505,7 +505,7 @@ describe('RokuFinder', () => { it('emits "device-online" again when alive arrives at an off-schedule time (e.g. reboot mid-cycle)', () => { const clock = sinon.useFakeTimers(); try { - finder = new RokuFinder(mockGlobalStateManager); + finder = new RokuFinder(mockHeartbeatProvider); finder['running'] = true; const deviceOnlineSpy = sinon.spy(); @@ -539,7 +539,7 @@ describe('RokuFinder', () => { it('uses serial number (not IP) as the heartbeat key so IP changes do not reset the clock', () => { const clock = sinon.useFakeTimers(); try { - finder = new RokuFinder(mockGlobalStateManager); + finder = new RokuFinder(mockHeartbeatProvider); finder['running'] = true; const deviceOnlineSpy = sinon.spy(); @@ -574,7 +574,7 @@ describe('RokuFinder', () => { it('tracks heartbeat independently per device', () => { const clock = sinon.useFakeTimers(); try { - finder = new RokuFinder(mockGlobalStateManager); + finder = new RokuFinder(mockHeartbeatProvider); finder['running'] = true; const deviceOnlineSpy = sinon.spy(); @@ -609,7 +609,7 @@ describe('RokuFinder', () => { }); it('emits "lost" on ssdp:byebye', async () => { - finder = new RokuFinder(mockGlobalStateManager); + finder = new RokuFinder(mockHeartbeatProvider); await finder.start(); const lostSpy = sinon.spy(); @@ -627,7 +627,7 @@ describe('RokuFinder', () => { }); it('ignores non-Roku notifications', async () => { - finder = new RokuFinder(mockGlobalStateManager); + finder = new RokuFinder(mockHeartbeatProvider); await finder.start(); const foundSpy = sinon.spy(); @@ -647,7 +647,7 @@ describe('RokuFinder', () => { }); it('debounces rapid ssdp:alive messages from same IP', async () => { - finder = new RokuFinder(mockGlobalStateManager); + finder = new RokuFinder(mockHeartbeatProvider); await finder.start(); const foundSpy = sinon.spy(); @@ -672,7 +672,7 @@ describe('RokuFinder', () => { it('allows ssdp:alive after debounce period expires', async () => { const clock = sinon.useFakeTimers(); try { - finder = new RokuFinder(mockGlobalStateManager); + finder = new RokuFinder(mockHeartbeatProvider); await finder.start(); const foundSpy = sinon.spy(); @@ -701,7 +701,7 @@ describe('RokuFinder', () => { }); it('debounces independently per IP address', async () => { - finder = new RokuFinder(mockGlobalStateManager); + finder = new RokuFinder(mockHeartbeatProvider); await finder.start(); const foundSpy = sinon.spy(); @@ -736,7 +736,7 @@ describe('RokuFinder', () => { it('cleans up stale debounce entries after 5 minutes', async () => { const clock = sinon.useFakeTimers(); try { - finder = new RokuFinder(mockGlobalStateManager); + finder = new RokuFinder(mockHeartbeatProvider); await finder.start(); const aliveMessage = { @@ -768,7 +768,7 @@ describe('RokuFinder', () => { describe('scan orchestration', () => { it('emits scan-started when scan begins', () => { - finder = new RokuFinder(mockGlobalStateManager); + finder = new RokuFinder(mockHeartbeatProvider); const scanStartedSpy = sinon.spy(); finder.on('scan-started', scanStartedSpy); @@ -781,7 +781,7 @@ describe('RokuFinder', () => { it('emits scan-ended after min duration and settle time', () => { const clock = sinon.useFakeTimers(); try { - finder = new RokuFinder(mockGlobalStateManager); + finder = new RokuFinder(mockHeartbeatProvider); const scanEndedSpy = sinon.spy(); finder.on('scan-ended', scanEndedSpy); @@ -803,7 +803,7 @@ describe('RokuFinder', () => { it('waits for settle timer even after min duration', () => { const clock = sinon.useFakeTimers(); try { - finder = new RokuFinder(mockGlobalStateManager); + finder = new RokuFinder(mockHeartbeatProvider); const scanEndedSpy = sinon.spy(); finder.on('scan-ended', scanEndedSpy); @@ -833,7 +833,7 @@ describe('RokuFinder', () => { it('does not start new scan if already scanning', () => { const clock = sinon.useFakeTimers(); try { - finder = new RokuFinder(mockGlobalStateManager); + finder = new RokuFinder(mockHeartbeatProvider); const scanStartedSpy = sinon.spy(); finder.on('scan-started', scanStartedSpy); @@ -859,7 +859,7 @@ describe('RokuFinder', () => { it('resets settle timer when device found via ssdp:alive', () => { const clock = sinon.useFakeTimers(); try { - finder = new RokuFinder(mockGlobalStateManager); + finder = new RokuFinder(mockHeartbeatProvider); finder['running'] = true; // Simulate started const scanEndedSpy = sinon.spy(); diff --git a/src/deviceDiscovery/RokuFinder.ts b/src/deviceDiscovery/RokuFinder.ts index e5381fee..ecd6ae98 100644 --- a/src/deviceDiscovery/RokuFinder.ts +++ b/src/deviceDiscovery/RokuFinder.ts @@ -1,11 +1,11 @@ import { EventEmitter } from 'eventemitter3'; import type { SsdpHeaders } from 'node-ssdp'; import { Client, Server } from 'node-ssdp'; -import type { GlobalStateManager } from '../GlobalStateManager'; +import type { HeartbeatProvider } from './DeviceStorageManager'; export class RokuFinder extends EventEmitter { constructor( - private globalStateManager: GlobalStateManager, + private heartbeatProvider: HeartbeatProvider, private log: (msg: string) => void = () => { } ) { super(); @@ -42,7 +42,7 @@ export class RokuFinder extends EventEmitter { * Heartbeat suppression: Roku devices send ssdp:alive on a ~20-minute schedule. * We emit device-online only when the alive does NOT arrive on schedule — meaning * the device just woke up or rebooted - * Timestamps are persisted via GlobalStateManager so the clock survives restarts. + * Timestamps are persisted via HeartbeatProvider so the clock survives restarts. * Keyed by serial number so a DHCP IP change doesn't reset the clock. * * Roku devices actually fire slightly less than 20minute interval. So use that value to help avoid overnight clock drift @@ -261,9 +261,9 @@ export class RokuFinder extends EventEmitter { */ private maybeEmitDeviceOnline(ip: string, serialNumber: string | undefined, now: number): void { const key = serialNumber ?? ip; - const lastTs = this.globalStateManager.getLastAliveTimestamp(key); + const lastTs = this.heartbeatProvider.getLastAliveTimestamp(key); const elapsed = lastTs !== undefined ? now - lastTs : undefined; - this.globalStateManager.setLastAliveTimestamp(key, now); + this.heartbeatProvider.setLastAliveTimestamp(key, now); const nearestMultiple = elapsed !== undefined ? Math.round(elapsed / this.HEARTBEAT_INTERVAL_MS) : 0; const isRoutineHeartbeat = elapsed !== undefined && diff --git a/src/extension.ts b/src/extension.ts index a8a4e4a7..31976d97 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -82,7 +82,7 @@ export class Extension { this.telemetryManager.sendStartupEvent(); this.extensionOutputChannel = util.createOutputChannel('BrightScript Extension', this.writeExtensionLog.bind(this)); this.extensionOutputChannel.appendLine('Extension startup'); - this.deviceManager = new DeviceManager(context, this.globalStateManager, this.extensionOutputChannel); + this.deviceManager = new DeviceManager(context, this.extensionOutputChannel); let userInputManager = new UserInputManager( this.deviceManager ); diff --git a/src/managers/UserInputManager.spec.ts b/src/managers/UserInputManager.spec.ts index 8f101968..100c4c25 100644 --- a/src/managers/UserInputManager.spec.ts +++ b/src/managers/UserInputManager.spec.ts @@ -8,7 +8,6 @@ import { standardizePath as s } from 'brighterscript'; import * as fsExtra from 'fs-extra'; import type { RokuDevice } from '../deviceDiscovery/DeviceManager'; import { DeviceManager } from '../deviceDiscovery/DeviceManager'; -import { GlobalStateManager } from '../GlobalStateManager'; import { icons } from '../icons'; const sinon = createSandbox(); @@ -30,7 +29,6 @@ describe('UserInputManager', () => { let userInputManager: UserInputManager; let deviceManager: DeviceManager; - let globalStateManager: GlobalStateManager; beforeEach(() => { fsExtra.emptyDirSync(tempDir); @@ -40,8 +38,7 @@ describe('UserInputManager', () => { sinon.stub(DeviceManager.prototype as any, 'setupConfiguration').callsFake(() => { }); sinon.stub(DeviceManager.prototype as any, 'setupWindowFocusHandling').callsFake(() => { }); sinon.stub(DeviceManager.prototype as any, 'setupMonitors').callsFake(() => { }); - globalStateManager = new GlobalStateManager(vscode.context); - deviceManager = new DeviceManager(vscode.context, globalStateManager); + deviceManager = new DeviceManager(vscode.context); userInputManager = new UserInputManager(deviceManager); });