From dfea59dea97067800106eef9adb6b399f1d3c3a0 Mon Sep 17 00:00:00 2001 From: Sean McManus Date: Fri, 28 Aug 2026 19:12:51 -0700 Subject: [PATCH] Fix deploySteps option handling --- Extension/package.json | 4 +- .../src/Debugger/configurationProvider.ts | 6 +- Extension/src/SSH/commands.ts | 9 +- Extension/src/common.ts | 2 +- Extension/test/unit/deploySteps.test.ts | 345 ++++++++++++++++++ Extension/tools/OptionsSchema.json | 2 +- 6 files changed, 358 insertions(+), 10 deletions(-) create mode 100644 Extension/test/unit/deploySteps.test.ts diff --git a/Extension/package.json b/Extension/package.json index fd39e0a54..9192cb95f 100644 --- a/Extension/package.json +++ b/Extension/package.json @@ -4899,7 +4899,7 @@ "recursive": { "type": "boolean", "description": "%c_cpp.debuggers.deploySteps.copyFile.recursive.description%", - "default": "true" + "default": true }, "debug": { "type": "boolean", @@ -5700,7 +5700,7 @@ "recursive": { "type": "boolean", "description": "%c_cpp.debuggers.deploySteps.copyFile.recursive.description%", - "default": "true" + "default": true }, "debug": { "type": "boolean", diff --git a/Extension/src/Debugger/configurationProvider.ts b/Extension/src/Debugger/configurationProvider.ts index e675516f8..d308dca4d 100644 --- a/Extension/src/Debugger/configurationProvider.ts +++ b/Extension/src/Debugger/configurationProvider.ts @@ -1115,9 +1115,9 @@ export class DebugConfigurationProvider implements vscode.DebugConfigurationProv let scpResult: util.ProcessReturnType; if (isScp) { - scpResult = await scp(files, host, step.targetDir, config.scpPath, config.recursive, jumpHosts, cancellationToken); + scpResult = await scp(files, host, step.targetDir, step.recursive, step.scpPath, jumpHosts, cancellationToken); } else { - scpResult = await rsync(files, host, step.targetDir, config.scpPath, config.recursive, jumpHosts, cancellationToken); + scpResult = await rsync(files, host, step.targetDir, step.recursive, step.rsyncPath, jumpHosts, cancellationToken); } if (!scpResult.succeeded || cancellationToken?.isCancellationRequested) { @@ -1134,7 +1134,7 @@ export class DebugConfigurationProvider implements vscode.DebugConfigurationProv const jumpHosts: util.ISshHostInfo[] = step.host.jumpHosts; const localForwards: util.ISshLocalForwardInfo[] = step.host.localForwards; const continueOn: string = step.continueOn; - const sshResult: util.ProcessReturnType = await ssh(host, step.command, config.sshPath, jumpHosts, localForwards, continueOn, cancellationToken); + const sshResult: util.ProcessReturnType = await ssh(host, step.command, step.sshPath, jumpHosts, localForwards, continueOn, cancellationToken); if (!sshResult.succeeded || cancellationToken?.isCancellationRequested) { return false; } diff --git a/Extension/src/SSH/commands.ts b/Extension/src/SSH/commands.ts index 159138f31..50c66b502 100644 --- a/Extension/src/SSH/commands.ts +++ b/Extension/src/SSH/commands.ts @@ -41,12 +41,15 @@ export async function rsync(files: vscode.Uri[], host: ISshHostInfo, targetDir: if (recursive) { args.push('-r'); } + const sshArgs: string[] = []; if (jumpHosts && jumpHosts.length > 0) { - args.push('-e', `ssh -J ${jumpHosts.map(getFullHostAddress).join(',')}`); + sshArgs.push('-J', jumpHosts.map(getFullHostAddress).join(',')); } if (host.port) { - // upper case P - args.push(`--port=${host.port}`); + sshArgs.push('-p', `${host.port}`); + } + if (sshArgs.length > 0) { + args.push('-e', `"ssh ${sshArgs.join(' ')}"`); } args.push(files.map(uri => `"${uri.fsPath}"`).join(' '), `${getFullHostAddressNoPort(host)}:${targetDir}`); diff --git a/Extension/src/common.ts b/Extension/src/common.ts index 700d8ab44..6c02a4467 100644 --- a/Extension/src/common.ts +++ b/Extension/src/common.ts @@ -822,7 +822,7 @@ async function spawnChildProcessImpl(program: string, args: string[], continueOn stdout += str; if (continueOn) { const continueOnReg: string = escapeStringForRegex(continueOn); - if (stdout.search(continueOnReg)) { + if (stdout.search(continueOnReg) >= 0) { result.resolve({ stdout: stdout.trim(), stderr: stderr.trim() }); } } diff --git a/Extension/test/unit/deploySteps.test.ts b/Extension/test/unit/deploySteps.test.ts new file mode 100644 index 000000000..71483ba7e --- /dev/null +++ b/Extension/test/unit/deploySteps.test.ts @@ -0,0 +1,345 @@ +/* -------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All Rights Reserved. + * See 'LICENSE' in the project root for license information. + * ------------------------------------------------------------------------------------------ */ + +import { deepStrictEqual, strictEqual } from 'assert'; +import * as childProcess from 'child_process'; +import { EventEmitter } from 'events'; +import { readFileSync } from 'fs'; +import { resolve } from 'path'; +import { afterEach, beforeEach, describe, it } from 'mocha'; +import * as proxyquire from 'proxyquire'; +import * as sinon from 'sinon'; + +interface HostInfo { + hostName: string; + user?: string; + port?: number | string; +} + +interface ProcessResult { + succeeded: boolean; + output: string; + outputError: string; +} + +interface TerminalCommandArgs { + command: string; +} + +interface SshCommandsModule { + scp(files: { fsPath: string; }[], host: HostInfo, targetDir: string, recursive?: boolean, scpPath?: string, jumpHosts?: HostInfo[]): Promise; + rsync(files: { fsPath: string; }[], host: HostInfo, targetDir: string, recursive?: boolean, rsyncPath?: string, jumpHosts?: HostInfo[]): Promise; + ssh(host: HostInfo, command: string, sshPath?: string, jumpHosts?: HostInfo[]): Promise; +} + +interface DebugConfigurationProviderForTest { + singleDeployStep(config: Record, step: Record): Promise; +} + +interface ConfigurationProviderModule { + DebugConfigurationProvider: new (assetProvider: unknown, type: string) => DebugConfigurationProviderForTest; +} + +interface CommonModule { + spawnChildProcess(program: string, args?: string[], continueOn?: string, skipLogging?: boolean): Promise; +} + +const successfulResult: ProcessResult = { succeeded: true, output: '', outputError: '' }; + +function getFullHostAddressNoPort(host: HostInfo): string { + return host.user ? `${host.user}@${host.hostName}` : host.hostName; +} + +function getFullHostAddress(host: HostInfo): string { + const address: string = getFullHostAddressNoPort(host); + return host.port ? `${address}:${host.port}` : address; +} + +function createModuleStub(overrides: Record = {}): any { + const fallback: any = new Proxy(function () { return fallback; }, { + get: (_target, property) => property === 'then' ? undefined : fallback, + apply: () => fallback, + construct: () => fallback + }); + + const target: Record = { '@noCallThru': true, ...overrides }; + return new Proxy(target, { + get: (stubTarget, property) => property in stubTarget ? stubTarget[property as string] : fallback + }); +} + +function createVscodeStub(): any { + return createModuleStub({ + '@global': true, + extensions: { getExtension: () => ({ packageJSON: { version: 'test' } }) }, + Uri: { file: (fsPath: string) => ({ fsPath }) } + }); +} + +describe('SSH deploy commands', () => { + let commands: SshCommandsModule; + let runCommandStub: sinon.SinonStub; + + beforeEach(() => { + proxyquire.noPreserveCache(); + runCommandStub = sinon.stub().resolves(successfulResult); + commands = proxyquire('../../src/SSH/commands', { + '../common': { + getFullHostAddress, + getFullHostAddressNoPort, + '@noCallThru': true + }, + './commandInteractors': { + defaultSystemInteractor: {}, + '@noCallThru': true + }, + './sshCommandRunner': { + runSshTerminalCommandWithLogin: runCommandStub, + '@noCallThru': true + } + }); + }); + + afterEach(() => sinon.restore()); + + it('honors a custom SCP path and disabled recursion', async () => { + const host: HostInfo = { hostName: 'target', user: 'dev', port: 2222 }; + const jumpHosts: HostInfo[] = [{ hostName: 'jump', user: 'proxy', port: 2200 }]; + + await commands.scp([{ fsPath: '/tmp/app' }], host, '/srv/app', false, '/opt/scp', jumpHosts); + + strictEqual((runCommandStub.firstCall.args[1] as TerminalCommandArgs).command, + '"/opt/scp" -J proxy@jump:2200 -P 2222 "/tmp/app" dev@target:/srv/app'); + }); + + it('uses the SSH remote shell for rsync jump hosts and ports', async () => { + const host: HostInfo = { hostName: 'target', user: 'dev', port: 2222 }; + const jumpHosts: HostInfo[] = [{ hostName: 'jump', user: 'proxy', port: 2200 }]; + + await commands.rsync([{ fsPath: '/tmp/app' }], host, '/srv/app', false, '/opt/rsync', jumpHosts); + + strictEqual((runCommandStub.firstCall.args[1] as TerminalCommandArgs).command, + '"/opt/rsync" -lKpvz -e "ssh -J proxy@jump:2200 -p 2222" "/tmp/app" dev@target:/srv/app'); + }); + + it('honors a custom SSH path', async () => { + const host: HostInfo = { hostName: 'target', user: 'dev', port: 2222 }; + const jumpHosts: HostInfo[] = [{ hostName: 'jump', user: 'proxy', port: 2200 }]; + + await commands.ssh(host, 'echo ready', '/opt/ssh', jumpHosts); + + strictEqual((runCommandStub.firstCall.args[1] as TerminalCommandArgs).command, + '"/opt/ssh" -J proxy@jump:2200 -p 2222 dev@target "echo ready"'); + }); +}); + +describe('deploy step option forwarding', () => { + let provider: DebugConfigurationProviderForTest; + let scpStub: sinon.SinonStub; + let rsyncStub: sinon.SinonStub; + let sshStub: sinon.SinonStub; + + beforeEach(() => { + proxyquire.noPreserveCache(); + scpStub = sinon.stub().resolves(successfulResult); + rsyncStub = sinon.stub().resolves(successfulResult); + sshStub = sinon.stub().resolves(successfulResult); + const globStub: any = (pattern: string, callback: (error: Error | null, matches: string[]) => void): void => callback(null, [`/resolved/${pattern}`]); + globStub['@noCallThru'] = true; + + const passthroughStubs: Record = { + '../constants': createModuleStub({ isWindows: false }), + '../expand': createModuleStub(), + '../LanguageServer/cppBuildTaskProvider': createModuleStub(), + '../LanguageServer/devcmd': createModuleStub(), + '../LanguageServer/extension': createModuleStub(), + '../LanguageServer/settings': createModuleStub(), + '../logger': createModuleStub(), + '../platform': createModuleStub(), + '../telemetry': createModuleStub(), + './attachQuickPick': createModuleStub(), + './attachToProcess': createModuleStub(), + './configurations': createModuleStub(), + './nativeAttach': createModuleStub(), + './ParsedEnvironmentFile': createModuleStub(), + './utils': createModuleStub() + }; + + const moduleUnderTest: ConfigurationProviderModule = proxyquire('../../src/Debugger/configurationProvider', { + ...passthroughStubs, + glob: globStub, + vscode: createVscodeStub(), + '../common': createModuleStub({ + isString: (value: unknown) => typeof value === 'string', + isArrayOfString: (value: unknown) => Array.isArray(value) && value.every(item => typeof item === 'string') + }), + '../SSH/commands': createModuleStub({ + scp: scpStub, + rsync: rsyncStub, + ssh: sshStub + }) + }); + provider = new moduleUnderTest.DebugConfigurationProvider({}, 'cppdbg'); + }); + + afterEach(() => sinon.restore()); + + const config: Record = { + noDebug: false, + recursive: true, + scpPath: '/wrong/scp', + rsyncPath: '/wrong/rsync', + sshPath: '/wrong/ssh' + }; + const host = { + hostName: 'target', + user: 'dev', + port: 2222, + jumpHosts: [{ hostName: 'jump', user: 'proxy', port: 2200 }] + }; + + it('forwards SCP options from the copy step', async () => { + const succeeded: boolean = await provider.singleDeployStep(config, { + type: 'scp', + files: 'app', + host, + targetDir: '/srv/app', + recursive: false, + scpPath: '/opt/scp' + }); + + strictEqual(succeeded, true); + sinon.assert.calledOnceWithExactly(scpStub, + [{ fsPath: '/resolved/app' }], + { hostName: 'target', user: 'dev', port: 2222 }, + '/srv/app', + false, + '/opt/scp', + host.jumpHosts, + undefined); + }); + + it('forwards rsync options from the copy step', async () => { + const succeeded: boolean = await provider.singleDeployStep(config, { + type: 'rsync', + files: 'app', + host, + targetDir: '/srv/app', + recursive: false, + rsyncPath: '/opt/rsync' + }); + + strictEqual(succeeded, true); + sinon.assert.calledOnceWithExactly(rsyncStub, + [{ fsPath: '/resolved/app' }], + { hostName: 'target', user: 'dev', port: 2222 }, + '/srv/app', + false, + '/opt/rsync', + host.jumpHosts, + undefined); + }); + + it('forwards the SSH path from the SSH step', async () => { + const succeeded: boolean = await provider.singleDeployStep(config, { + type: 'ssh', + command: 'echo ready', + host, + sshPath: '/opt/ssh' + }); + + strictEqual(succeeded, true); + sinon.assert.calledOnceWithExactly(sshStub, + { hostName: 'target', user: 'dev', port: 2222 }, + 'echo ready', + '/opt/ssh', + host.jumpHosts, + undefined, + undefined, + undefined); + }); +}); + +describe('shell deploy step continueOn', () => { + let common: CommonModule; + let fakeProcess: EventEmitter & { stdout: EventEmitter; stderr: EventEmitter; kill: sinon.SinonStub; }; + let spawnStub: sinon.SinonStub; + + beforeEach(() => { + proxyquire.noPreserveCache(); + fakeProcess = Object.assign(new EventEmitter(), { + stdout: new EventEmitter(), + stderr: new EventEmitter(), + kill: sinon.stub() + }); + spawnStub = sinon.stub().returns(fakeProcess); + common = proxyquire('../../src/common', { + child_process: { + ...childProcess, + spawn: spawnStub, + '@noCallThru': true + }, + vscode: createVscodeStub(), + './logger': createModuleStub({ + getOutputChannelLogger: () => createModuleStub() + }), + './telemetry': createModuleStub() + }); + }); + + afterEach(() => sinon.restore()); + + async function waitForSpawn(): Promise { + for (let attempt: number = 0; attempt < 10 && !spawnStub.called; attempt++) { + await new Promise(resolvePromise => setImmediate(resolvePromise)); + } + strictEqual(spawnStub.called, true, 'child process was not spawned'); + } + + async function isSettled(promise: Promise): Promise { + let settled: boolean = false; + void promise.then(() => settled = true, () => settled = true); + await new Promise(resolvePromise => setImmediate(resolvePromise)); + return settled; + } + + it('does not resolve when the pattern is absent', async () => { + const processPromise: Promise = common.spawnChildProcess(process.execPath, [], 'ready', true); + await waitForSpawn(); + + fakeProcess.stdout.emit('data', Buffer.from('working')); + + strictEqual(await isSettled(processPromise), false); + fakeProcess.emit('close', 0, null); + strictEqual((await processPromise).succeeded, true); + }); + + it('resolves when the pattern starts at offset zero', async () => { + const processPromise: Promise = common.spawnChildProcess(process.execPath, [], 'ready', true); + await waitForSpawn(); + + fakeProcess.stdout.emit('data', Buffer.from('ready')); + + strictEqual(await isSettled(processPromise), true); + deepStrictEqual(await processPromise, { succeeded: true, exitCode: undefined, outputError: '', output: 'ready' }); + }); +}); + +describe('deploy step schema', () => { + function recursiveDefault(document: any, generated: boolean): unknown { + const deploySteps: any = generated + ? document.contributes.debuggers[0].configurationAttributes.launch.properties.deploySteps + : document.definitions.DeploySteps; + return deploySteps.items.anyOf[0].properties.recursive.default; + } + + it('uses a boolean recursive default in the source and generated schemas', () => { + const optionsSchema: any = JSON.parse(readFileSync(resolve(__dirname, '../../../tools/OptionsSchema.json'), 'utf8')); + const packageJson: any = JSON.parse(readFileSync(resolve(__dirname, '../../../package.json'), 'utf8')); + + strictEqual(recursiveDefault(optionsSchema, false), true); + strictEqual(recursiveDefault(packageJson, true), true); + }); +}); diff --git a/Extension/tools/OptionsSchema.json b/Extension/tools/OptionsSchema.json index 010af9a76..819bc5358 100644 --- a/Extension/tools/OptionsSchema.json +++ b/Extension/tools/OptionsSchema.json @@ -482,7 +482,7 @@ "recursive": { "type": "boolean", "description": "%c_cpp.debuggers.deploySteps.copyFile.recursive.description%", - "default": "true" + "default": true }, "debug": { "type": "boolean",