diff --git a/package-lock.json b/package-lock.json index 001caef1..e452a000 100644 --- a/package-lock.json +++ b/package-lock.json @@ -34,8 +34,8 @@ "postman-request": "^2.88.1-postman.48", "pretty-bytes": "^5.6.0", "resolve": "^1.22.8", - "roku-debug": "^0.23.14", - "roku-deploy": "^3.17.6", + "roku-debug": "^0.23.15", + "roku-deploy": "^3.17.7", "roku-test-automation": "^2.2.2", "semver": "^7.1.3", "source-map": "^0.7.3", @@ -4572,19 +4572,11 @@ "node": ">=0.10" } }, - "node_modules/dateformat": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/dateformat/-/dateformat-3.0.3.tgz", - "integrity": "sha512-jyCETtSl3VMZMWeRo7iY1FL19ges1t55hMo5yaam4Jrsm5EPL89UQkoQRyiI+Yf4k8r2ZpdngkV8hr1lIdjb3Q==", - "license": "MIT", - "engines": { - "node": "*" - } - }, "node_modules/dayjs": { "version": "1.11.12", "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.12.tgz", - "integrity": "sha512-Rt2g+nTbLlDWZTwwrIXjy9MeiZmSDI375FvZs72ngxx8PDC6YXOeR3q5LAuPzjZQxhiWdRKac7RKV+YyQYfYIg==" + "integrity": "sha512-Rt2g+nTbLlDWZTwwrIXjy9MeiZmSDI375FvZs72ngxx8PDC6YXOeR3q5LAuPzjZQxhiWdRKac7RKV+YyQYfYIg==", + "dev": true }, "node_modules/debounce": { "version": "1.2.1", @@ -8656,14 +8648,6 @@ "url": "https://github.com/chalk/supports-color?sponsor=1" } }, - "node_modules/moment": { - "version": "2.29.4", - "resolved": "https://registry.npmjs.org/moment/-/moment-2.29.4.tgz", - "integrity": "sha512-5LC9SOxjSc2HF6vO2CyuTDNivEdoz2IvyJJGj6X8DJ0eFyfszE0QiEd+iXmBvUP3WHxSjFH/vIsA0EN00cgr8w==", - "engines": { - "node": "*" - } - }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -9464,14 +9448,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/parse-ms": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/parse-ms/-/parse-ms-2.1.0.tgz", - "integrity": "sha512-kHt7kzLoS9VBZfUsiKjv43mr91ea+U05EyKkEtqp7vNbHxmaVuEqN7XxeEVnGrMtYOAxGrDElSi96K7EgO1zCA==", - "engines": { - "node": ">=6" - } - }, "node_modules/parse-semver": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/parse-semver/-/parse-semver-1.1.1.tgz", @@ -10511,9 +10487,10 @@ } }, "node_modules/roku-debug": { - "version": "0.23.14", - "resolved": "https://registry.npmjs.org/roku-debug/-/roku-debug-0.23.14.tgz", - "integrity": "sha512-iyVkxBt44ulq1FEnbTY27Plr74DmMqd8XcIWtI4GiZI3pEtfndOy8YUxmnw2jDlFyrlxFXAAdWtVMJexWM5vGQ==", + "version": "0.23.15", + "resolved": "https://registry.npmjs.org/roku-debug/-/roku-debug-0.23.15.tgz", + "integrity": "sha512-77+JWQklqNn23qXPI42M+HdA7QRB/8PhS3+TeCi1CpgUYKsrpgcbP8czpFzcvpsV8eCgiV9tg1p0m/geCSJfbw==", + "license": "MIT", "dependencies": { "@rokucommunity/logger": "^0.4.0", "@types/request": "^2.48.8", @@ -10533,7 +10510,7 @@ "postman-request": "^2.88.1-postman.48", "replace-in-file": "^6.3.2", "replace-last": "^1.2.6", - "roku-deploy": "^3.17.6", + "roku-deploy": "^3.17.7", "semver": "^7.5.4", "serialize-error": "^8.1.0", "smart-buffer": "^4.2.0", @@ -10606,26 +10583,22 @@ } }, "node_modules/roku-deploy": { - "version": "3.17.6", - "resolved": "https://registry.npmjs.org/roku-deploy/-/roku-deploy-3.17.6.tgz", - "integrity": "sha512-5G28I0eNCpKCv34f6jCNvY8e746RE4uTWisGBZSiUqyKnraN6NIZo5VimNWMBajICCAqzUjpqLmJN9q5KXq8mw==", + "version": "3.17.7", + "resolved": "https://registry.npmjs.org/roku-deploy/-/roku-deploy-3.17.7.tgz", + "integrity": "sha512-aEgi9Vq8/D0NhJzY97CtEUUgUwfR0/F20QR/JIMKXKDxAYt8alfgV217WShTbnFMyYYnJflTMRCHP2bJBbriGQ==", + "license": "MIT", "dependencies": { "@types/request": "^2.48.13", "chalk": "^2.4.2", - "dateformat": "^3.0.3", - "dayjs": "^1.11.0", "fast-glob": "^3.2.12", "fs-extra": "^7.0.1", "is-glob": "^4.0.3", "jsonc-parser": "^2.3.0", "jszip": "^3.6.0", - "lodash": "^4.17.21", "micromatch": "^4.0.4", - "moment": "^2.29.1", - "parse-ms": "^2.1.0", + "picomatch": "^2.3.2", "postman-request": "^2.88.1-postman.48", "semver": "^7.7.3", - "temp-dir": "^2.0.0", "xml2js": "^0.5.0" }, "bin": { @@ -11895,15 +11868,6 @@ "url": "https://paypal.me/kozjak" } }, - "node_modules/temp-dir": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/temp-dir/-/temp-dir-2.0.0.tgz", - "integrity": "sha512-aoBAniQmmwtcKp/7BzsH8Cxzv8OL736p7v1ihGb5e9DJ9kTwGWHrQrVB5+lfVDzfGrdRzXch+ig7LHaY1JTOrg==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/terminal-link": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/terminal-link/-/terminal-link-4.0.0.tgz", diff --git a/package.json b/package.json index da172379..820ef7e5 100644 --- a/package.json +++ b/package.json @@ -77,8 +77,8 @@ "postman-request": "^2.88.1-postman.48", "pretty-bytes": "^5.6.0", "resolve": "^1.22.8", - "roku-debug": "^0.23.14", - "roku-deploy": "^3.17.6", + "roku-debug": "^0.23.15", + "roku-deploy": "^3.17.7", "roku-test-automation": "^2.2.2", "semver": "^7.1.3", "source-map": "^0.7.3", diff --git a/src/BrightScriptCommands.spec.ts b/src/BrightScriptCommands.spec.ts index 763d78a7..8d77fb34 100644 --- a/src/BrightScriptCommands.spec.ts +++ b/src/BrightScriptCommands.spec.ts @@ -18,6 +18,7 @@ Module.prototype.require = function hijacked(file) { import { BrightScriptCommands } from './BrightScriptCommands'; import { util } from './util'; import { rokuDeploy } from 'roku-deploy'; +import { vscodeContextManager } from './managers/VscodeContextManager'; describe('BrightScriptFileUtils ', () => { let commands: BrightScriptCommands; @@ -292,7 +293,7 @@ describe('BrightScriptFileUtils ', () => { }; //password resolution is delegated to UserInputManager (tested in its own spec) userInputManager = { - promptForHost: sandbox.stub().resolves('1.2.3.4'), + promptForHost: sandbox.stub().resolves({ host: '1.2.3.4', deviceInfo: undefined }), resolveDevicePassword: sandbox.stub().resolves({ status: 'ok', password: 'pw' }) }; //ctor: remoteControlManager, whatsNewManager, context, deviceManager, userInputManager, localPackageManager, credentialStore @@ -443,6 +444,49 @@ describe('BrightScriptFileUtils ', () => { }); }); + describe('getHealthyActiveHost', () => { + let sandbox: sinon.SinonSandbox; + let localCommands: BrightScriptCommands; + let deviceManager: any; + + beforeEach(() => { + sandbox = sinon.createSandbox(); + deviceManager = { + healthCheckDevice: sandbox.stub().resolves(true), + getDevice: sandbox.stub().returns({ ip: '1.2.3.4', deviceInfo: { 'serial-number': 'SN123' } }) + }; + localCommands = new BrightScriptCommands({} as any, {} as any, vscode.context, deviceManager, {} as any, {} as any, {} as any); + sandbox.stub(vscodeContextManager, 'get').returns('1.2.3.4'); + }); + + afterEach(() => { + sandbox.restore(); + }); + + it('returns the host with its device info when the active host is healthy', async () => { + const result = await localCommands.getHealthyActiveHost(); + assert.deepEqual(result, { host: '1.2.3.4', deviceInfo: { 'serial-number': 'SN123' } }); + }); + + it('returns undefined when no active host is set', async () => { + (vscodeContextManager.get as sinon.SinonStub).returns(undefined); + const result = await localCommands.getHealthyActiveHost(); + assert.isUndefined(result); + }); + + it('returns undefined when the active host fails the health check', async () => { + deviceManager.healthCheckDevice.resolves(false); + const result = await localCommands.getHealthyActiveHost(); + assert.isUndefined(result); + }); + + it('returns undefined when no device info could be read back', async () => { + deviceManager.getDevice.returns(undefined); + const result = await localCommands.getHealthyActiveHost(); + assert.isUndefined(result); + }); + }); + describe('onToggleXml ', () => { it('does nothing when no active document', () => { vscode.window.activeTextEditor = undefined; diff --git a/src/BrightScriptCommands.ts b/src/BrightScriptCommands.ts index 91bf9cd7..609fe6d5 100644 --- a/src/BrightScriptCommands.ts +++ b/src/BrightScriptCommands.ts @@ -10,7 +10,7 @@ import { util } from './util'; import { util as rokuDebugUtil } from 'roku-debug/dist/util'; import type { RemoteControlManager, RemoteControlModeInitiator } from './managers/RemoteControlManager'; import type { WhatsNewManager } from './managers/WhatsNewManager'; -import type { ConfiguredDevice, DeviceManager, RokuDevice } from './deviceDiscovery/DeviceManager'; +import type { ConfiguredDevice, DeviceManager, HostWithDeviceInfo, RokuDevice } from './deviceDiscovery/DeviceManager'; import * as xml2js from 'xml2js'; import { firstBy } from 'thenby'; import type { UserInputManager } from './managers/UserInputManager'; @@ -387,7 +387,7 @@ export class BrightScriptCommands { this.registerCommand('openRegistryInBrowser', async (host: string) => { if (!host) { - host = await this.userInputManager.promptForHost(); + host = (await this.userInputManager.promptForHost())?.host; } let responseText = await util.spinAsync('Fetching app list', async () => { @@ -439,7 +439,7 @@ export class BrightScriptCommands { ip = deviceOrItem; } if (!ip) { - ip = await this.userInputManager.promptForHost(); + ip = (await this.userInputManager.promptForHost())?.host; } if (!ip) { throw new Error('Tried to set active device but failed.'); @@ -855,7 +855,7 @@ export class BrightScriptCommands { private async resolveDeviceHost(host?: string): Promise<{ host: string; serialNumber: string | undefined; label: string } | undefined> { if (!host) { try { - host = await this.userInputManager.promptForHost(); + host = (await this.userInputManager.promptForHost())?.host; } catch { // promptForHost rejects when the user dismisses the picker; treat as a cancel. return undefined; @@ -929,7 +929,7 @@ export class BrightScriptCommands { this.host = config.get('host'); // eslint-disable-next-line no-template-curly-in-string if ((!this.host || this.host === '${promptForHost}') && showPrompt) { - this.host = await this.userInputManager.promptForHost(); + this.host = (await this.userInputManager.promptForHost())?.host; } } if (!this.host) { @@ -1086,15 +1086,25 @@ export class BrightScriptCommands { } /** - * Return the active host IP if one is set and passes a health check; otherwise undefined. + * Return the active host (paired with its raw device-info) if one is set and passes a health + * check; otherwise undefined. The health check refreshes the device in the device manager, so + * the device-info is read back from there without an extra request. */ - public async getHealthyActiveHost(): Promise { + public async getHealthyActiveHost(): Promise { const activeHost = vscodeContextManager.get('activeHost'); if (!activeHost) { return undefined; } const isHealthy = await this.deviceManager.healthCheckDevice({ ip: activeHost }, true, false); - return isHealthy ? activeHost : undefined; + if (!isHealthy) { + return undefined; + } + const deviceInfo = this.deviceManager.getDevice({ ip: activeHost })?.deviceInfo; + //deviceInfo is required on HostWithDeviceInfo, so if we couldn't read it back, report no healthy active host + if (!deviceInfo) { + return undefined; + } + return { host: activeHost, deviceInfo: deviceInfo }; } /** diff --git a/src/DebugConfigurationProvider.spec.ts b/src/DebugConfigurationProvider.spec.ts index 759f8597..d3e345b4 100644 --- a/src/DebugConfigurationProvider.spec.ts +++ b/src/DebugConfigurationProvider.spec.ts @@ -1,4 +1,4 @@ -import { expect } from 'chai'; +import { expect, assert } from 'chai'; import * as path from 'path'; import { createSandbox } from 'sinon'; import type { WorkspaceFolder } from 'vscode'; @@ -84,8 +84,8 @@ describe('BrightScriptConfigurationProvider', () => { // Override any properties that would cause a prompt if not overridden configDefaults.host = '192.168.1.100'; configDefaults.password = 'aaaa'; - //return an empty deviceInfo response - sinon.stub(rokuDeploy, 'getDeviceInfo').returns(Promise.reject(new Error('Failure during test'))); + //probing the host resolves a reachable developer device + sinon.stub(rokuDeploy, 'getDeviceInfo').resolves({ 'developer-enabled': 'true', 'serial-number': 'SN-TEST' } as any); // short-circuit the password candidate validation loop so tests can focus on config resolution logic sinon.stub(DeviceManager.prototype, 'validateDevicePassword').resolves('ok'); }); @@ -152,6 +152,124 @@ describe('BrightScriptConfigurationProvider', () => { expect(config.password).to.equal('pass1234'); }); + describe('device info', () => { + it('attaches the raw device info from the probed device to the resolved config without a separate network request', async () => { + sinon.stub(configProvider, 'getBsConfig').returns({}); + const deviceInfo = { + 'serial-number': 'abc123', + 'developer-enabled': 'true', + 'software-version': '11.5.0' + }; + sinon.stub(deviceManager, 'validateAndAddDevice').resolves({ ip: '1.2.3.4', deviceInfo: deviceInfo } as any); + + const config = await configProvider.resolveDebugConfiguration(folder, { + host: '1.2.3.4', + type: 'brightscript', + password: 'aaaa' + }); + + //the raw device info should be passed straight through to the launch config + expect(config.deviceInfo).to.eql(deviceInfo); + //device info is reused from the probe, so no extra getDeviceInfo network request should happen + expect((rokuDeploy.getDeviceInfo as any).called).to.be.false; + }); + + it('throws when the probed device reports developer mode disabled', async () => { + sinon.stub(configProvider, 'getBsConfig').returns({}); + sinon.stub(deviceManager, 'validateAndAddDevice').resolves({ + ip: '1.2.3.4', + deviceInfo: { 'developer-enabled': 'false' } + } as any); + + try { + await configProvider.resolveDebugConfiguration(folder, { + host: '1.2.3.4', + type: 'brightscript', + password: 'aaaa' + }); + assert.fail('Should have thrown exception'); + } catch (e) { + expect((e as Error)?.message).to.contain('developer mode is disabled'); + } + }); + + it('throws when the probed device returns no device info', async () => { + sinon.stub(configProvider, 'getBsConfig').returns({}); + sinon.stub(deviceManager, 'validateAndAddDevice').resolves({ ip: '1.2.3.4', deviceInfo: {} } as any); + + try { + await configProvider.resolveDebugConfiguration(folder, { + host: '1.2.3.4', + type: 'brightscript', + password: 'aaaa' + }); + assert.fail('Should have thrown exception'); + } catch (e) { + expect((e as Error)?.message).to.contain('unable to reach device'); + } + }); + }); + + describe('processHostParameter', () => { + it('reuses the device from the picker instead of probing again', async () => { + const deviceInfo = { 'serial-number': 'abc123', 'developer-enabled': 'true' }; + const device = { ip: '1.2.3.4', serialNumber: 'abc123', deviceInfo: deviceInfo } as any; + //the picker resolved the host, so the device is already registered + (configProvider as any).brightScriptCommands = { getHealthyActiveHost: sinon.stub().resolves(undefined) }; + sinon.stub(userInputManager, 'promptForHost').resolves({ host: '1.2.3.4', deviceInfo: deviceInfo }); + const getDeviceStub = sinon.stub(deviceManager, 'getDevice').returns(device); + const validateStub = sinon.stub(deviceManager, 'validateAndAddDevice').resolves(undefined); + + const result = await (configProvider as any).processHostParameter({ host: '' }); + + expect(getDeviceStub.calledWith({ ip: '1.2.3.4' })).to.be.true; + expect(validateStub.called).to.be.false; + expect(result.deviceInfo).to.eql(deviceInfo); + }); + + it('reuses the device from the healthy active host instead of probing again', async () => { + const deviceInfo = { 'serial-number': 'abc123', 'developer-enabled': 'true' }; + const device = { ip: '1.2.3.4', serialNumber: 'abc123', deviceInfo: deviceInfo } as any; + //the active-host lookup resolved the host, so the device is already registered + (configProvider as any).brightScriptCommands = { getHealthyActiveHost: sinon.stub().resolves({ host: '1.2.3.4', deviceInfo: deviceInfo }) }; + const promptStub = sinon.stub(userInputManager, 'promptForHost'); + const getDeviceStub = sinon.stub(deviceManager, 'getDevice').returns(device); + const validateStub = sinon.stub(deviceManager, 'validateAndAddDevice').resolves(undefined); + + const result = await (configProvider as any).processHostParameter({ host: '' }); + + expect(promptStub.called).to.be.false; + expect(getDeviceStub.calledWith({ ip: '1.2.3.4' })).to.be.true; + expect(validateStub.called).to.be.false; + expect(result.deviceInfo).to.eql(deviceInfo); + }); + + it('probes the host and attaches its device info when not chosen through the picker', async () => { + const deviceInfo = { 'serial-number': 'abc123' }; + const device = { ip: '1.2.3.4', serialNumber: 'abc123', deviceInfo: deviceInfo } as any; + const getDeviceStub = sinon.stub(deviceManager, 'getDevice'); + const validateStub = sinon.stub(deviceManager, 'validateAndAddDevice').resolves(device); + + const result = await (configProvider as any).processHostParameter({ host: '1.2.3.4' }); + + expect(validateStub.calledWith('1.2.3.4')).to.be.true; + expect(getDeviceStub.called).to.be.false; + expect(result.deviceInfo).to.eql(deviceInfo); + }); + + it('throws when the probe yields no device info', async () => { + sinon.stub(deviceManager, 'validateAndAddDevice').resolves({ ip: '1.2.3.4', deviceInfo: {} } as any); + + let threw: Error | undefined; + try { + await (configProvider as any).processHostParameter({ host: '1.2.3.4' }); + } catch (e) { + threw = e as Error; + } + expect(threw?.message).to.contain('unable to reach device'); + }); + }); + it('uses the default values if not provided', async () => { const config = await configProvider.resolveDebugConfiguration(folder, { type: 'brightscript' }); const configDefaults = (configProvider as any).configDefaults; @@ -458,7 +576,13 @@ describe('BrightScriptConfigurationProvider', () => { config: Partial, result: Partial, device: any - ) => (configProvider as any).processPasswordParameter(config, result, device); + ) => { + //processPasswordParameter now reads the serial number from result.deviceInfo instead of a device arg + if (device?.serialNumber) { + (result as any).deviceInfo = { 'serial-number': device.serialNumber }; + } + return (configProvider as any).processPasswordParameter(config, result); + }; /** * Register the given serial number in `brightscript.devices[]` user settings diff --git a/src/DebugConfigurationProvider.ts b/src/DebugConfigurationProvider.ts index 396be81e..37ed5f7f 100644 --- a/src/DebugConfigurationProvider.ts +++ b/src/DebugConfigurationProvider.ts @@ -135,23 +135,17 @@ export class BrightScriptDebugConfigurationProvider implements DebugConfiguratio result = await this.sanitizeConfiguration(result, folder); result = await this.processEnvVariables(folder, result); - const [resultAfterHost, device] = await this.processHostParameter(result); - result = resultAfterHost; - result = await this.processPasswordParameter(config, result, device); + result = await this.processHostParameter(result); + result = await this.processPasswordParameter(config, result); result = await this.processDeepLinkUrlParameter(result); result = await this.processLogfilePath(folder, result); result = this.processDapLogFilePath(folder, result); - const statusbarItem = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Right, 9_999_999); - statusbarItem.text = '$(sync~spin) Fetching device info'; - statusbarItem.show(); - try { - deviceInfo = await rokuDeploy.getDeviceInfo({ host: result.host, remotePort: result.remotePort, enhance: true, timeout: 4000 }); - } catch (e) { - // a failed deviceInfo request should NOT fail the launch - console.error(`Failed to fetch device info for ${result.host}`, e); + // `processHostParameter` attached the raw device-info it gathered while probing the host. + // Enhance a local copy here for the developer-mode check + telemetry (no request to the device). + if (result.deviceInfo) { + deviceInfo = rokuDeploy.enhanceDeviceInfo(result.deviceInfo); } - statusbarItem.dispose(); if (deviceInfo && !deviceInfo.developerEnabled) { throw new Error(`Cannot deploy: developer mode is disabled on '${result.host}'`); @@ -494,13 +488,12 @@ export class BrightScriptDebugConfigurationProvider implements DebugConfiguratio * ${activeHost} is a deprecated alias for ${promptForHost}. * Both use the active device when it's set and passes a health check, otherwise fall back to the device picker. * - * Returns the updated config alongside the probed `RokuDevice` so downstream - * password resolution can look up credentials by serial number without - * re-fetching device info. Device is undefined when the resolved host is - * unreachable or not a developer-enabled Roku. + * Assigns the raw `device-info` gathered while probing the resolved host onto `config.deviceInfo`, + * so downstream password resolution and the debug session can reuse it without re-fetching. + * Throws if the device couldn't be reached (no device-info came back). * @param config current config object */ - private async processHostParameter(config: BrightScriptLaunchConfiguration): Promise<[BrightScriptLaunchConfiguration, RokuDevice | undefined]> { + private async processHostParameter(config: BrightScriptLaunchConfiguration): Promise { const trimmedHost = config.host.trim(); const needsHostPrompt = trimmedHost === '' || @@ -508,12 +501,16 @@ export class BrightScriptDebugConfigurationProvider implements DebugConfiguratio trimmedHost === '${activeHost}' || config?.deepLinkUrl?.includes('${promptForHost}'); + let device: RokuDevice | undefined; + if (needsHostPrompt) { - const healthyActiveHost = await this.brightScriptCommands.getHealthyActiveHost(); - if (healthyActiveHost) { - config.host = healthyActiveHost; - } else { - config.host = await this.userInputManager.promptForHost(); + // both the active-host lookup and the picker probe + register the device in the device + // manager, so reuse it below instead of probing again + const resolved = await this.brightScriptCommands.getHealthyActiveHost() ?? + await this.userInputManager.promptForHost(); + config.host = resolved?.host; + if (resolved?.host) { + device = this.deviceManager.getDevice({ ip: resolved.host }); } } @@ -524,11 +521,19 @@ export class BrightScriptDebugConfigurationProvider implements DebugConfiguratio await this.context.workspaceState.update('remoteHost', config.host); } - // Probe the resolved host so downstream password resolution has fresh SN/deviceInfo. - // Unreachable or filtered hosts yield no registered device; password resolution handles that. - const device = await this.deviceManager.validateAndAddDevice(config.host); + // If the host didn't come from the picker, probe it so we have fresh SN/deviceInfo. + device ??= await this.deviceManager.validateAndAddDevice(config.host); - return [config, device]; + // A reachable developer device always returns device-info; its absence means we couldn't reach it. + if (!device?.deviceInfo || Object.keys(device.deviceInfo).length === 0) { + throw new Error(`Debug session terminated: unable to reach device at '${config.host}'.`); + } + + // Attach the raw device-info so downstream password resolution and the debug session can reuse it + // without another request to the device. + config.deviceInfo = device.deviceInfo; + + return config; } /** @@ -543,17 +548,15 @@ export class BrightScriptDebugConfigurationProvider implements DebugConfiguratio * the user is prompted. * * @param config the raw launch configuration as received from VS Code - * @param result the merged/resolved config being built up - * @param device the probed device from `processHostParameter`, or undefined - * when the host is unreachable / not a developer Roku + * @param result the merged/resolved config being built up. Its `deviceInfo` (set by + * `processHostParameter`) supplies the serial number used to look up credentials. */ private async processPasswordParameter( config: BrightScriptLaunchConfiguration, - result: BrightScriptLaunchConfiguration, - device: RokuDevice | undefined + result: BrightScriptLaunchConfiguration ): Promise { const host = result.host; - const serialNumber = device?.serialNumber; + const serialNumber = result.deviceInfo?.['serial-number']; // Opportunistically drain any legacy IP-keyed password that still lives in // workspaceState from pre-refactor extension installs. Reads never consult diff --git a/src/SceneGraphDebugCommands.ts b/src/SceneGraphDebugCommands.ts index cf481715..4db114e6 100644 --- a/src/SceneGraphDebugCommands.ts +++ b/src/SceneGraphDebugCommands.ts @@ -190,7 +190,7 @@ export class SceneGraphDebugCommands { this.host = config.get('host'); // eslint-disable-next-line no-template-curly-in-string if (!this.host || this.host === '${promptForHost}') { - this.host = await this.userInputManager.promptForHost(); + this.host = (await this.userInputManager.promptForHost())?.host; } } if (!this.host) { diff --git a/src/commands/RekeyAndPackageCommand.ts b/src/commands/RekeyAndPackageCommand.ts index 8b646157..075ad735 100644 --- a/src/commands/RekeyAndPackageCommand.ts +++ b/src/commands/RekeyAndPackageCommand.ts @@ -99,7 +99,7 @@ export class RekeyAndPackageCommand { } private async getRekeyManualEntries(rekeyConfig: RekeyConfig, defaultValues) { - rekeyConfig.host = await this.userInputManager.promptForHost({ defaultValue: rekeyConfig?.host ?? defaultValues?.host }); + rekeyConfig.host = (await this.userInputManager.promptForHost({ defaultValue: rekeyConfig?.host ?? defaultValues?.host }))?.host; rekeyConfig.password = await vscode.window.showInputBox({ title: 'Enter password for the Roku device you want to rekey', @@ -234,7 +234,7 @@ export class RekeyAndPackageCommand { break; } - rokuDeployOptions.host = await this.userInputManager.promptForHost({ defaultValue: rokuDeployOptions?.host ?? '' }); + rokuDeployOptions.host = (await this.userInputManager.promptForHost({ defaultValue: rokuDeployOptions?.host ?? '' }))?.host; rokuDeployOptions.password = await vscode.window.showInputBox({ title: 'Enter password for the Roku device', diff --git a/src/deviceDiscovery/DeviceManager.ts b/src/deviceDiscovery/DeviceManager.ts index f88de566..5f7678d6 100644 --- a/src/deviceDiscovery/DeviceManager.ts +++ b/src/deviceDiscovery/DeviceManager.ts @@ -1408,6 +1408,16 @@ export type PasswordValidationResult = 'ok' | 'bad-password' | 'unreachable'; export type ConfigurationScope = 'user' | 'workspace'; +/** + * A resolved host paired with the raw `device-info` gathered while probing it. Returned by the + * host-resolution flows (device picker, manual entry, active-host lookup) so callers can reuse the + * device info without issuing another request to the device. + */ +export interface HostWithDeviceInfo { + host: string; + deviceInfo: DeviceInfoRaw; +} + /** * User-configured device from settings (brightscript.devices) */ diff --git a/src/managers/UserInputManager.spec.ts b/src/managers/UserInputManager.spec.ts index 8cb370de..1cc1f013 100644 --- a/src/managers/UserInputManager.spec.ts +++ b/src/managers/UserInputManager.spec.ts @@ -247,6 +247,89 @@ describe('UserInputManager', () => { quickPick?.hide(); await promptPromise.catch(() => { }); }); + + it('returns the host and the raw device info for a typed-in IP', async () => { + let quickPick: any; + const originalCreateQuickPick = vscode.window.createQuickPick; + sinon.stub(vscode.window, 'createQuickPick').callsFake(() => { + quickPick = originalCreateQuickPick(); + return quickPick; + }); + + const deviceInfo = { + 'serial-number': 'abc123', + 'software-version': '11.5.0' + }; + sinon.stub(deviceManager, 'validateAndAddDevice').resolves({ ip: '1.2.3.4', deviceInfo: deviceInfo } as any); + + const promptPromise = userInputManager.promptForHost(); + + //let the picker finish setting up + await new Promise(resolve => { + setTimeout(resolve, 10); + }); + + //simulate the user typing an IP and pressing enter + quickPick.value = '1.2.3.4'; + quickPick.emitter.emit('didAccept'); + + const result = await promptPromise; + expect(result).to.eql({ host: '1.2.3.4', deviceInfo: deviceInfo }); + }); + + it('returns the manually-entered host along with the probed device info', async () => { + let quickPick: any; + const originalCreateQuickPick = vscode.window.createQuickPick; + sinon.stub(vscode.window, 'createQuickPick').callsFake(() => { + quickPick = originalCreateQuickPick(); + return quickPick; + }); + const deviceInfo = { + 'serial-number': 'manual123', + 'software-version': '11.5.0' + }; + //manual entry probes the device the same way, so the gathered device info comes back too + sinon.stub(userInputManager, 'promptForHostManual').resolves({ host: '9.8.7.6', deviceInfo: deviceInfo }); + + const promptPromise = userInputManager.promptForHost(); + + await new Promise(resolve => { + setTimeout(resolve, 10); + }); + + //simulate the user selecting the "manual entry" item + quickPick.emitter.emit('didChangeSelection', [{ id: manualHostItemId, label: 'Enter manually' }]); + quickPick.emitter.emit('didAccept'); + + const result = await promptPromise; + expect(result).to.eql({ host: '9.8.7.6', deviceInfo: deviceInfo }); + }); + }); + + describe('promptForHostManual', () => { + it('probes the typed host and returns it with the gathered device info', async () => { + sinon.stub(vscode.window, 'showInputBox').resolves('4.3.2.1'); + const deviceInfo = { + 'serial-number': 'manual123', + 'software-version': '11.5.0' + }; + const validateStub = sinon.stub(deviceManager, 'validateAndAddDevice').resolves({ ip: '4.3.2.1', deviceInfo: deviceInfo } as any); + + const result = await userInputManager.promptForHostManual(); + + expect(validateStub.calledWith('4.3.2.1')).to.be.true; + expect(result).to.eql({ host: '4.3.2.1', deviceInfo: deviceInfo }); + }); + + it('returns undefined when the user cancels the input box', async () => { + sinon.stub(vscode.window, 'showInputBox').resolves(undefined); + const validateStub = sinon.stub(deviceManager, 'validateAndAddDevice'); + + const result = await userInputManager.promptForHostManual(); + + expect(result).to.be.undefined; + expect(validateStub.called).to.be.false; + }); }); describe('collectDevicePasswordCandidates', () => { diff --git a/src/managers/UserInputManager.ts b/src/managers/UserInputManager.ts index 35c6eeac..48b54ed4 100644 --- a/src/managers/UserInputManager.ts +++ b/src/managers/UserInputManager.ts @@ -1,10 +1,11 @@ import { Deferred } from 'brighterscript'; +import type { DeviceInfoRaw } from 'roku-deploy'; import type { Disposable, QuickPickItem } from 'vscode'; import * as vscode from 'vscode'; -import type { ConfiguredDevice, DeviceManager, RokuDevice } from '../deviceDiscovery/DeviceManager'; +import type { ConfiguredDevice, DeviceManager, HostWithDeviceInfo, RokuDevice } from '../deviceDiscovery/DeviceManager'; import type { CredentialStore } from './CredentialStore'; import { icons } from '../icons'; import { vscodeContextManager } from './VscodeContextManager'; @@ -47,7 +48,7 @@ export class UserInputManager { private credentialStore: CredentialStore ) { } - public async promptForHostManual(): Promise { + public async promptForHostManual(): Promise { while (true) { const value = await vscode.window.showInputBox({ placeHolder: 'Please enter the IP address of your Roku device', @@ -63,7 +64,8 @@ export class UserInputManager { } ); if (probed) { - return probed.ip; + //probing gathers the device info the same way as the picker; return it alongside the host + return { host: probed.ip, deviceInfo: probed.deviceInfo }; } await vscode.window.showErrorMessage(`Unable to connect to a Roku at ${value}. Check the IP and confirm developer mode is enabled.`); } @@ -203,9 +205,9 @@ export class UserInputManager { /** * Prompt the user to pick a host from a list of devices */ - public async promptForHost(options?: { defaultValue?: string }) { + public async promptForHost(options?: { defaultValue?: string }): Promise { - const deferred = new Deferred<{ ip: string; manual?: boolean } | { ip?: string; manual: true }>(); + const deferred = new Deferred<{ ip: string; deviceInfo: DeviceInfoRaw; manual?: false } | { manual: true }>(); const disposables: Array = []; //create the quickpick item @@ -276,7 +278,7 @@ export class UserInputManager { return; } this.deviceManager.setLastUsedDeviceIp(device.ip); - deferred.resolve(device); + deferred.resolve({ ip: device.ip, deviceInfo: device.deviceInfo }); } quickPick.dispose(); } @@ -293,7 +295,7 @@ export class UserInputManager { return; } this.deviceManager.setLastUsedDeviceIp(probed.ip); - deferred.resolve({ ip: probed.ip }); + deferred.resolve({ ip: probed.ip, deviceInfo: probed.deviceInfo }); quickPick.dispose(); } }); @@ -430,10 +432,10 @@ export class UserInputManager { refreshList(); const result = await deferred.promise; dispose(); - if (result?.manual === true) { + if (result.manual === true) { return this.promptForHostManual(); } else { - return result?.ip; + return { host: result.ip, deviceInfo: result.deviceInfo }; } } diff --git a/src/mockVscode.spec.ts b/src/mockVscode.spec.ts index 8cf2251b..069e014f 100644 --- a/src/mockVscode.spec.ts +++ b/src/mockVscode.spec.ts @@ -224,12 +224,16 @@ export let vscode = { }), createQuickPick: () => { class QuickPick { - private emitter = new EventEmitter(); + public emitter = new EventEmitter(); public placeholder = ''; public items: QuickPickItem[]; public keepScrollPosition = false; + public value = ''; + public busy = false; + public buttons: any[] = []; + public activeItems: QuickPickItem[] = []; public show() { } @@ -249,6 +253,14 @@ export let vscode = { this.emitter.on('didChangeSelection', cb); } + public onDidChangeActive(cb) { + this.emitter.on('didChangeActive', cb); + } + + public onDidTriggerButton(cb) { + this.emitter.on('didTriggerButton', cb); + } + public dispose() { this.emitter.removeAllListeners(); }