Skip to content

Commit

Permalink
Debug cancelation (#23262)
Browse files Browse the repository at this point in the history
  • Loading branch information
eleanorjboyd authored Apr 22, 2024
1 parent 6aaca06 commit 51cf852
Show file tree
Hide file tree
Showing 5 changed files with 235 additions and 6 deletions.
28 changes: 25 additions & 3 deletions src/client/testing/common/debugLauncher.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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';
Expand Down Expand Up @@ -48,7 +48,29 @@ export class DebugLauncher implements ITestDebugLauncher {
);
const debugManager = this.serviceContainer.get<IDebugService>(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?.();
});
Expand Down
2 changes: 1 addition & 1 deletion src/client/testing/testController/common/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
Expand Down
6 changes: 6 additions & 0 deletions src/test/testing/common/debugLauncher.unit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
204 changes: 202 additions & 2 deletions src/test/testing/common/testingAdapter.test.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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;
Expand Down Expand Up @@ -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: {
Expand Down Expand Up @@ -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>(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<IPythonExecutionService>();
const execFactoryStub = typeMoq.Mock.ofType<IPythonExecutionFactory>();
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<Observable<Output<string>>>().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<DebugSession>();
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>();
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>(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<IPythonExecutionService>();
const execFactoryStub = typeMoq.Mock.ofType<IPythonExecutionFactory>();
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<Observable<Output<string>>>().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<DebugSession>();
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>();
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');
});
});
});

0 comments on commit 51cf852

Please sign in to comment.