diff --git a/src/client/testing/common/debugLauncher.ts b/src/client/testing/common/debugLauncher.ts index f9c7747d883a..0ec38eb9c87c 100644 --- a/src/client/testing/common/debugLauncher.ts +++ b/src/client/testing/common/debugLauncher.ts @@ -1,6 +1,6 @@ import { inject, injectable, named } from 'inversify'; import * as path from 'path'; -import { DebugConfiguration, l10n, Uri, WorkspaceFolder } from 'vscode'; +import { debug, DebugConfiguration, Disposable, l10n, Uri, WorkspaceFolder } from 'vscode'; import { IApplicationShell, IDebugService } from '../../common/application/types'; import { EXTENSION_ROOT_DIR } from '../../common/constants'; import * as internalScripts from '../../common/process/internal/scripts'; @@ -9,7 +9,7 @@ import { DebuggerTypeName, PythonDebuggerTypeName } from '../../debugger/constan import { IDebugConfigurationResolver } from '../../debugger/extension/configuration/types'; import { DebugPurpose, LaunchRequestArguments } from '../../debugger/types'; import { IServiceContainer } from '../../ioc/types'; -import { traceError } from '../../logging'; +import { traceError, traceLog } from '../../logging'; import { TestProvider } from '../types'; import { ITestDebugLauncher, LaunchOptions } from './types'; import { getConfigurationsForWorkspace } from '../../debugger/extension/configuration/launch.json/launchJsonReader'; @@ -48,7 +48,29 @@ export class DebugLauncher implements ITestDebugLauncher { ); const debugManager = this.serviceContainer.get(IDebugService); - debugManager.onDidTerminateDebugSession(() => { + let disposeOfDebugger: Disposable | undefined; + const disposeOfStartDebugging = debugManager.onDidStartDebugSession((session) => { + if (options.token) { + disposeOfDebugger = options?.token.onCancellationRequested(() => { + console.log('Canceling debugger, due to cancelation token called.'); + debug.stopDebugging(session); + }); + } + }); + + let disposeTerminateWatcher: Disposable | undefined; + // eslint-disable-next-line prefer-const + disposeTerminateWatcher = debugManager.onDidTerminateDebugSession(() => { + traceLog('Terminating the debugging session and disposing of debugger listeners.'); + if (disposeOfDebugger !== undefined) { + disposeOfDebugger.dispose(); + } + if (disposeOfStartDebugging !== undefined) { + disposeOfStartDebugging.dispose(); + } + if (disposeTerminateWatcher !== undefined) { + disposeTerminateWatcher.dispose(); + } deferred.resolve(); callback?.(); }); diff --git a/src/client/testing/testController/common/utils.ts b/src/client/testing/testController/common/utils.ts index 759fb0713de4..0bdecc5b8257 100644 --- a/src/client/testing/testController/common/utils.ts +++ b/src/client/testing/testController/common/utils.ts @@ -220,7 +220,7 @@ export async function startRunResultNamedPipe( perConnectionDisposables.push( // per connection, add a listener for the cancellation token and the data cancellationToken?.onCancellationRequested(() => { - console.log(`Test Result named pipe ${pipeName} cancelled`); + traceVerbose(`Test Result named pipe ${pipeName} cancelled`); // if cancel is called on one connection, dispose of all connections disposeOfServer(); }), diff --git a/src/client/testing/testController/pytest/pytestExecutionAdapter.ts b/src/client/testing/testController/pytest/pytestExecutionAdapter.ts index 5099efde179c..090ae787ed60 100644 --- a/src/client/testing/testController/pytest/pytestExecutionAdapter.ts +++ b/src/client/testing/testController/pytest/pytestExecutionAdapter.ts @@ -167,6 +167,7 @@ export class PytestTestExecutionAdapter implements ITestExecutionAdapter { }; traceInfo(`Running DEBUG pytest with arguments: ${testArgs} for workspace ${uri.fsPath} \r\n`); await debugLauncher!.launchDebugger(launchOptions, () => { + traceInfo("Debugger callback called, resolving 'till EOT' deferred for the workspace."); serverDispose(); // this will resolve deferredTillServerClose deferredTillEOT?.resolve(); }); diff --git a/src/test/testing/common/debugLauncher.unit.test.ts b/src/test/testing/common/debugLauncher.unit.test.ts index ef3e678c13f7..6a72aa7586f0 100644 --- a/src/test/testing/common/debugLauncher.unit.test.ts +++ b/src/test/testing/common/debugLauncher.unit.test.ts @@ -139,6 +139,12 @@ suite('Unit Tests - Debug Launcher', () => { return undefined as any; }) .verifiable(TypeMoq.Times.once()); + debugService + .setup((d) => d.onDidStartDebugSession(TypeMoq.It.isAny())) + .returns(() => { + return undefined as any; + }) + .verifiable(TypeMoq.Times.once()); } function createWorkspaceFolder(folderPath: string): WorkspaceFolder { return { diff --git a/src/test/testing/common/testingAdapter.test.ts b/src/test/testing/common/testingAdapter.test.ts index c01fed29bac7..572ea7e19836 100644 --- a/src/test/testing/common/testingAdapter.test.ts +++ b/src/test/testing/common/testingAdapter.test.ts @@ -1,15 +1,17 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. -import { TestController, TestRun, Uri } from 'vscode'; +import { CancellationTokenSource, DebugSession, TestController, TestRun, Uri, debug } from 'vscode'; import * as typeMoq from 'typemoq'; import * as path from 'path'; import * as assert from 'assert'; import * as fs from 'fs'; +import * as sinon from 'sinon'; +import { Observable } from 'rxjs'; import * as os from 'os'; import { PytestTestDiscoveryAdapter } from '../../../client/testing/testController/pytest/pytestDiscoveryAdapter'; import { ITestController, ITestResultResolver } from '../../../client/testing/testController/common/types'; -import { IPythonExecutionFactory } from '../../../client/common/process/types'; +import { IPythonExecutionFactory, IPythonExecutionService, Output } from '../../../client/common/process/types'; import { IConfigurationService, ITestOutputChannel } from '../../../client/common/types'; import { IServiceContainer } from '../../../client/ioc/types'; import { EXTENSION_ROOT_DIR_FOR_TESTS, initialize } from '../../initialize'; @@ -21,6 +23,9 @@ import { PythonResultResolver } from '../../../client/testing/testController/com import { TestProvider } from '../../../client/testing/types'; import { PYTEST_PROVIDER, UNITTEST_PROVIDER } from '../../../client/testing/common/constants'; import { IEnvironmentVariablesProvider } from '../../../client/common/variables/types'; +import { ITestDebugLauncher } from '../../../client/testing/common/types'; +import { MockChildProcess } from '../../mocks/mockChildProcess'; +import { createDeferred } from '../../../client/common/utils/async'; suite('End to End Tests: test adapters', () => { let resultResolver: ITestResultResolver; @@ -150,6 +155,9 @@ suite('End to End Tests: test adapters', () => { traceLog('Symlink was not found to remove after tests, exiting successfully, nestedSymlink.'); } }); + teardown(async () => { + sinon.restore(); + }); test('unittest discovery adapter small workspace', async () => { // result resolver and saved data for assertions let actualData: { @@ -1073,4 +1081,196 @@ suite('End to End Tests: test adapters', () => { assert.strictEqual(failureOccurred, false, failureMsg); }); }); + test('Pytest debug cancelation', async () => { + const debugLauncher = serviceContainer.get(ITestDebugLauncher); + const stopDebuggingStub = sinon.stub(debug, 'stopDebugging'); + let calledStopDebugging = false; + stopDebuggingStub.callsFake(() => { + calledStopDebugging = true; + return Promise.resolve(); + }); + + // // mock exec service and exec factory, not very necessary for this test + const execServiceStub = typeMoq.Mock.ofType(); + const execFactoryStub = typeMoq.Mock.ofType(); + const cancellationTokenSource = new CancellationTokenSource(); + let mockProc: MockChildProcess; + execServiceStub + .setup((x) => x.execObservable(typeMoq.It.isAny(), typeMoq.It.isAny())) + .returns(() => ({ + proc: mockProc, + out: typeMoq.Mock.ofType>>().object, + dispose: () => { + /* no-body */ + }, + })); + execFactoryStub + .setup((x) => x.createActivatedEnvironment(typeMoq.It.isAny())) + .returns(() => Promise.resolve(execServiceStub.object)); + execFactoryStub.setup((p) => ((p as unknown) as any).then).returns(() => undefined); + execServiceStub.setup((p) => ((p as unknown) as any).then).returns(() => undefined); + + resultResolver = new PythonResultResolver(testController, pytestProvider, workspaceUri); + + const testId = `${rootPathErrorWorkspace}/test_seg_fault.py::TestSegmentationFault::test_segfault`; + const testIds: string[] = [testId]; + + // set workspace to test workspace folder + workspaceUri = Uri.parse(rootPathErrorWorkspace); + configService.getSettings(workspaceUri).testing.pytestArgs = []; + + const debugSessionStub = typeMoq.Mock.ofType(); + sinon.stub(debug, 'onDidStartDebugSession').callsFake((cb) => { + // run the callback right away to add the cancelation token listener + cb(debugSessionStub.object); + return { + dispose: () => { + /* no-body */ + }, + }; + }); + const awaitStopDebugging = createDeferred(); + + sinon.stub(debug, 'onDidTerminateDebugSession').callsFake((cb) => { + // wait for the stop debugging to be called before resolving the promise + // the terminate debug session does cleanup + awaitStopDebugging.promise.then(() => { + cb(debugSessionStub.object); + }); + return { + dispose: () => { + // void + }, + }; + }); + // handle cancelation token from debugger + sinon.stub(debug, 'startDebugging').callsFake((folder, nameOrConfiguration, _parentSession) => { + // check to make sure start debugging is called correctly + if (typeof nameOrConfiguration !== 'string') { + assert.strictEqual(nameOrConfiguration.type, 'debugpy', 'Expected debugpy'); + } else { + assert.fail('Expected nameOrConfiguration to be an object'); + } + assert.ok(folder, 'Expected folder to be defined'); + assert.strictEqual(folder.name, 'test', 'Expected folder name to be test'); + // cancel the token and trigger the stop debugging callback + awaitStopDebugging.resolve(); + cancellationTokenSource.cancel(); + return Promise.resolve(true); + }); + + // run pytest execution + const executionAdapter = new PytestTestExecutionAdapter( + configService, + testOutputChannel.object, + resultResolver, + envVarsService, + ); + + const testRun = typeMoq.Mock.ofType(); + testRun.setup((t) => t.token).returns(() => cancellationTokenSource.token); + + await executionAdapter + .runTests(workspaceUri, testIds, true, testRun.object, pythonExecFactory, debugLauncher) + .finally(() => { + // verify that the stop debugging was called + assert.ok(calledStopDebugging, 'Expected stopDebugging to be called'); + }); + }); + test('UNITTEST debug cancelation', async () => { + const debugLauncher = serviceContainer.get(ITestDebugLauncher); + const stopDebuggingStub = sinon.stub(debug, 'stopDebugging'); + let calledStopDebugging = false; + stopDebuggingStub.callsFake(() => { + calledStopDebugging = true; + return Promise.resolve(); + }); + + // // mock exec service and exec factory, not very necessary for this test + const execServiceStub = typeMoq.Mock.ofType(); + const execFactoryStub = typeMoq.Mock.ofType(); + const cancellationTokenSource = new CancellationTokenSource(); + let mockProc: MockChildProcess; + execServiceStub + .setup((x) => x.execObservable(typeMoq.It.isAny(), typeMoq.It.isAny())) + .returns(() => ({ + proc: mockProc, + out: typeMoq.Mock.ofType>>().object, + dispose: () => { + /* no-body */ + }, + })); + execFactoryStub + .setup((x) => x.createActivatedEnvironment(typeMoq.It.isAny())) + .returns(() => Promise.resolve(execServiceStub.object)); + execFactoryStub.setup((p) => ((p as unknown) as any).then).returns(() => undefined); + execServiceStub.setup((p) => ((p as unknown) as any).then).returns(() => undefined); + + resultResolver = new PythonResultResolver(testController, pytestProvider, workspaceUri); + + const testId = `${rootPathErrorWorkspace}/test_seg_fault.py::TestSegmentationFault::test_segfault`; + const testIds: string[] = [testId]; + + // set workspace to test workspace folder + workspaceUri = Uri.parse(rootPathErrorWorkspace); + configService.getSettings(workspaceUri).testing.pytestArgs = []; + + const debugSessionStub = typeMoq.Mock.ofType(); + sinon.stub(debug, 'onDidStartDebugSession').callsFake((cb) => { + // run the callback right away to add the cancelation token listener + cb(debugSessionStub.object); + return { + dispose: () => { + /* no-body */ + }, + }; + }); + const awaitStopDebugging = createDeferred(); + + sinon.stub(debug, 'onDidTerminateDebugSession').callsFake((cb) => { + // wait for the stop debugging to be called before resolving the promise + // the terminate debug session does cleanup + awaitStopDebugging.promise.then(() => { + cb(debugSessionStub.object); + }); + return { + dispose: () => { + // void + }, + }; + }); + // handle cancelation token from debugger + sinon.stub(debug, 'startDebugging').callsFake((folder, nameOrConfiguration, _parentSession) => { + // check to make sure start debugging is called correctly + if (typeof nameOrConfiguration !== 'string') { + assert.strictEqual(nameOrConfiguration.type, 'debugpy', 'Expected debugpy'); + } else { + assert.fail('Expected nameOrConfiguration to be an object'); + } + assert.ok(folder, 'Expected folder to be defined'); + assert.strictEqual(folder.name, 'test', 'Expected folder name to be test'); + // cancel the token and trigger the stop debugging callback + awaitStopDebugging.resolve(); + cancellationTokenSource.cancel(); + return Promise.resolve(true); + }); + + // run pytest execution + const executionAdapter = new UnittestTestExecutionAdapter( + configService, + testOutputChannel.object, + resultResolver, + envVarsService, + ); + + const testRun = typeMoq.Mock.ofType(); + testRun.setup((t) => t.token).returns(() => cancellationTokenSource.token); + + await executionAdapter + .runTests(workspaceUri, testIds, true, testRun.object, pythonExecFactory, debugLauncher) + .finally(() => { + // verify that the stop debugging was called + assert.ok(calledStopDebugging, 'Expected stopDebugging to be called'); + }); + }); });