diff --git a/src/client/testing/testController/common/types.ts b/src/client/testing/testController/common/types.ts index 692025a05f40..7139788a8177 100644 --- a/src/client/testing/testController/common/types.ts +++ b/src/client/testing/testController/common/types.ts @@ -156,18 +156,19 @@ export interface ITestResultResolver { } export interface ITestDiscoveryAdapter { // ** first line old method signature, second line new method signature - discoverTests(uri: Uri): Promise; + discoverTests(uri: Uri): Promise; discoverTests( uri: Uri, - executionFactory: IPythonExecutionFactory, + executionFactory?: IPythonExecutionFactory, + token?: CancellationToken, interpreter?: PythonEnvironment, - ): Promise; + ): Promise; } // interface for execution/runner adapter export interface ITestExecutionAdapter { // ** first line old method signature, second line new method signature - runTests(uri: Uri, testIds: string[], profileKind?: boolean | TestRunProfileKind): Promise; + runTests(uri: Uri, testIds: string[], profileKind?: boolean | TestRunProfileKind): Promise; runTests( uri: Uri, testIds: string[], @@ -176,7 +177,7 @@ export interface ITestExecutionAdapter { executionFactory?: IPythonExecutionFactory, debugLauncher?: ITestDebugLauncher, interpreter?: PythonEnvironment, - ): Promise; + ): Promise; } // Same types as in python_files/unittestadapter/utils.py diff --git a/src/client/testing/testController/pytest/pytestDiscoveryAdapter.ts b/src/client/testing/testController/pytest/pytestDiscoveryAdapter.ts index ff73b31435a3..ef68f7d8039d 100644 --- a/src/client/testing/testController/pytest/pytestDiscoveryAdapter.ts +++ b/src/client/testing/testController/pytest/pytestDiscoveryAdapter.ts @@ -1,15 +1,16 @@ // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. import * as path from 'path'; -import { Uri } from 'vscode'; +import { CancellationToken, CancellationTokenSource, Uri } from 'vscode'; import * as fs from 'fs'; +import { ChildProcess } from 'child_process'; import { ExecutionFactoryCreateWithEnvironmentOptions, IPythonExecutionFactory, SpawnOptions, } from '../../../common/process/types'; import { IConfigurationService, ITestOutputChannel } from '../../../common/types'; -import { Deferred } from '../../../common/utils/async'; +import { createDeferred, Deferred } from '../../../common/utils/async'; import { EXTENSION_ROOT_DIR } from '../../../constants'; import { traceError, traceInfo, traceVerbose, traceWarn } from '../../../logging'; import { DiscoveredTestPayload, ITestDiscoveryAdapter, ITestResultResolver } from '../common/types'; @@ -40,24 +41,39 @@ export class PytestTestDiscoveryAdapter implements ITestDiscoveryAdapter { async discoverTests( uri: Uri, executionFactory?: IPythonExecutionFactory, + token?: CancellationToken, interpreter?: PythonEnvironment, - ): Promise { - const name = await startDiscoveryNamedPipe((data: DiscoveredTestPayload) => { - this.resultResolver?.resolveDiscovery(data); + ): Promise { + const cSource = new CancellationTokenSource(); + const deferredReturn = createDeferred(); + + token?.onCancellationRequested(() => { + traceInfo(`Test discovery cancelled.`); + cSource.cancel(); + deferredReturn.resolve(); }); - await this.runPytestDiscovery(uri, name, executionFactory, interpreter); + const name = await startDiscoveryNamedPipe((data: DiscoveredTestPayload) => { + // if the token is cancelled, we don't want process the data + if (!token?.isCancellationRequested) { + this.resultResolver?.resolveDiscovery(data); + } + }, cSource.token); + + this.runPytestDiscovery(uri, name, cSource, executionFactory, interpreter, token).then(() => { + deferredReturn.resolve(); + }); - // this is only a placeholder to handle function overloading until rewrite is finished - const discoveryPayload: DiscoveredTestPayload = { cwd: uri.fsPath, status: 'success' }; - return discoveryPayload; + return deferredReturn.promise; } async runPytestDiscovery( uri: Uri, discoveryPipeName: string, + cSource: CancellationTokenSource, executionFactory?: IPythonExecutionFactory, interpreter?: PythonEnvironment, + token?: CancellationToken, ): Promise { const relativePathToPytest = 'python_files'; const fullPluginPath = path.join(EXTENSION_ROOT_DIR, relativePathToPytest); @@ -111,6 +127,12 @@ export class PytestTestDiscoveryAdapter implements ITestDiscoveryAdapter { args: execArgs, env: (mutableEnv as unknown) as { [key: string]: string }, }); + token?.onCancellationRequested(() => { + traceInfo(`Test discovery cancelled, killing pytest subprocess for workspace ${uri.fsPath}`); + proc.kill(); + deferredTillExecClose.resolve(); + cSource.cancel(); + }); proc.stdout.on('data', (data) => { const out = fixLogLinesNoTrailing(data.toString()); traceInfo(out); @@ -143,6 +165,7 @@ export class PytestTestDiscoveryAdapter implements ITestDiscoveryAdapter { throwOnStdErr: true, outputChannel: this.outputChannel, env: mutableEnv, + token, }; // Create the Python environment in which to execute the command. @@ -154,7 +177,21 @@ export class PytestTestDiscoveryAdapter implements ITestDiscoveryAdapter { const execService = await executionFactory?.createActivatedEnvironment(creationOptions); const deferredTillExecClose: Deferred = createTestingDeferred(); + + let resultProc: ChildProcess | undefined; + + token?.onCancellationRequested(() => { + traceInfo(`Test discovery cancelled, killing pytest subprocess for workspace ${uri.fsPath}`); + // if the resultProc exists just call kill on it which will handle resolving the ExecClose deferred, otherwise resolve the deferred here. + if (resultProc) { + resultProc?.kill(); + } else { + deferredTillExecClose.resolve(); + cSource.cancel(); + } + }); const result = execService?.execObservable(execArgs, spawnOptions); + resultProc = result?.proc; // Take all output from the subprocess and add it to the test output channel. This will be the pytest output. // Displays output to user and ensure the subprocess doesn't run into buffer overflow. diff --git a/src/client/testing/testController/pytest/pytestExecutionAdapter.ts b/src/client/testing/testController/pytest/pytestExecutionAdapter.ts index b408280a576e..f66bff584fe2 100644 --- a/src/client/testing/testController/pytest/pytestExecutionAdapter.ts +++ b/src/client/testing/testController/pytest/pytestExecutionAdapter.ts @@ -38,7 +38,7 @@ export class PytestTestExecutionAdapter implements ITestExecutionAdapter { executionFactory?: IPythonExecutionFactory, debugLauncher?: ITestDebugLauncher, interpreter?: PythonEnvironment, - ): Promise { + ): Promise { const deferredTillServerClose: Deferred = utils.createTestingDeferred(); // create callback to handle data received on the named pipe @@ -59,12 +59,6 @@ export class PytestTestExecutionAdapter implements ITestExecutionAdapter { ); runInstance?.token.onCancellationRequested(() => { traceInfo(`Test run cancelled, resolving 'TillServerClose' deferred for ${uri.fsPath}.`); - const executionPayload: ExecutionTestPayload = { - cwd: uri.fsPath, - status: 'success', - error: '', - }; - return executionPayload; }); try { @@ -82,15 +76,6 @@ export class PytestTestExecutionAdapter implements ITestExecutionAdapter { } finally { await deferredTillServerClose.promise; } - - // placeholder until after the rewrite is adopted - // TODO: remove after adoption. - const executionPayload: ExecutionTestPayload = { - cwd: uri.fsPath, - status: 'success', - error: '', - }; - return executionPayload; } private async runTestsNew( @@ -244,7 +229,6 @@ export class PytestTestExecutionAdapter implements ITestExecutionAdapter { }); const result = execService?.execObservable(runArgs, spawnOptions); - resultProc = result?.proc; // Take all output from the subprocess and add it to the test output channel. This will be the pytest output. // Displays output to user and ensure the subprocess doesn't run into buffer overflow. diff --git a/src/client/testing/testController/unittest/testDiscoveryAdapter.ts b/src/client/testing/testController/unittest/testDiscoveryAdapter.ts index 04518e121651..73eb3f5aec2b 100644 --- a/src/client/testing/testController/unittest/testDiscoveryAdapter.ts +++ b/src/client/testing/testController/unittest/testDiscoveryAdapter.ts @@ -2,7 +2,9 @@ // Licensed under the MIT License. import * as path from 'path'; -import { Uri } from 'vscode'; +import { CancellationTokenSource, Uri } from 'vscode'; +import { CancellationToken } from 'vscode-jsonrpc'; +import { ChildProcess } from 'child_process'; import { IConfigurationService, ITestOutputChannel } from '../../../common/types'; import { EXTENSION_ROOT_DIR } from '../../../constants'; import { @@ -40,15 +42,31 @@ export class UnittestTestDiscoveryAdapter implements ITestDiscoveryAdapter { private readonly envVarsService?: IEnvironmentVariablesProvider, ) {} - public async discoverTests(uri: Uri, executionFactory?: IPythonExecutionFactory): Promise { + public async discoverTests( + uri: Uri, + executionFactory?: IPythonExecutionFactory, + token?: CancellationToken, + ): Promise { const settings = this.configSettings.getSettings(uri); const { unittestArgs } = settings.testing; const cwd = settings.testing.cwd && settings.testing.cwd.length > 0 ? settings.testing.cwd : uri.fsPath; - const name = await startDiscoveryNamedPipe((data: DiscoveredTestPayload) => { - this.resultResolver?.resolveDiscovery(data); + const cSource = new CancellationTokenSource(); + // Create a deferred to return to the caller + const deferredReturn = createDeferred(); + + token?.onCancellationRequested(() => { + traceInfo(`Test discovery cancelled.`); + cSource.cancel(); + deferredReturn.resolve(); }); + const name = await startDiscoveryNamedPipe((data: DiscoveredTestPayload) => { + if (!token?.isCancellationRequested) { + this.resultResolver?.resolveDiscovery(data); + } + }, cSource.token); + // set up env with the pipe name let env: EnvironmentVariables | undefined = await this.envVarsService?.getEnvironmentVariables(uri); if (env === undefined) { @@ -62,17 +80,14 @@ export class UnittestTestDiscoveryAdapter implements ITestDiscoveryAdapter { command, cwd, outChannel: this.outputChannel, + token, }; - try { - await this.runDiscovery(uri, options, name, cwd, executionFactory); - } finally { - // none - } - // placeholder until after the rewrite is adopted - // TODO: remove after adoption. - const discoveryPayload: DiscoveredTestPayload = { cwd, status: 'success' }; - return discoveryPayload; + this.runDiscovery(uri, options, name, cwd, cSource, executionFactory).then(() => { + deferredReturn.resolve(); + }); + + return deferredReturn.promise; } async runDiscovery( @@ -80,6 +95,7 @@ export class UnittestTestDiscoveryAdapter implements ITestDiscoveryAdapter { options: TestCommandOptions, testRunPipeName: string, cwd: string, + cSource: CancellationTokenSource, executionFactory?: IPythonExecutionFactory, ): Promise { // get and edit env vars @@ -103,6 +119,12 @@ export class UnittestTestDiscoveryAdapter implements ITestDiscoveryAdapter { args, env: (mutableEnv as unknown) as { [key: string]: string }, }); + options.token?.onCancellationRequested(() => { + traceInfo(`Test discovery cancelled, killing unittest subprocess for workspace ${uri.fsPath}`); + proc.kill(); + deferredTillExecClose.resolve(); + cSource.cancel(); + }); proc.stdout.on('data', (data) => { const out = fixLogLinesNoTrailing(data.toString()); traceInfo(out); @@ -148,7 +170,19 @@ export class UnittestTestDiscoveryAdapter implements ITestDiscoveryAdapter { }; const execService = await executionFactory?.createActivatedEnvironment(creationOptions); + let resultProc: ChildProcess | undefined; + options.token?.onCancellationRequested(() => { + traceInfo(`Test discovery cancelled, killing unittest subprocess for workspace ${uri.fsPath}`); + // if the resultProc exists just call kill on it which will handle resolving the ExecClose deferred, otherwise resolve the deferred here. + if (resultProc) { + resultProc?.kill(); + } else { + deferredTillExecClose.resolve(); + cSource.cancel(); + } + }); const result = execService?.execObservable(args, spawnOptions); + resultProc = result?.proc; // Displays output to user and ensure the subprocess doesn't run into buffer overflow. // TODO: after a release, remove discovery output from the "Python Test Log" channel and send it to the "Python" channel instead. diff --git a/src/client/testing/testController/unittest/testExecutionAdapter.ts b/src/client/testing/testController/unittest/testExecutionAdapter.ts index 6db36d96149f..e2b591379335 100644 --- a/src/client/testing/testController/unittest/testExecutionAdapter.ts +++ b/src/client/testing/testController/unittest/testExecutionAdapter.ts @@ -47,7 +47,7 @@ export class UnittestTestExecutionAdapter implements ITestExecutionAdapter { runInstance?: TestRun, executionFactory?: IPythonExecutionFactory, debugLauncher?: ITestDebugLauncher, - ): Promise { + ): Promise { // deferredTillServerClose awaits named pipe server close const deferredTillServerClose: Deferred = utils.createTestingDeferred(); @@ -87,12 +87,6 @@ export class UnittestTestExecutionAdapter implements ITestExecutionAdapter { } finally { await deferredTillServerClose.promise; } - const executionPayload: ExecutionTestPayload = { - cwd: uri.fsPath, - status: 'success', - error: '', - }; - return executionPayload; } private async runTestsNew( diff --git a/src/client/testing/testController/workspaceTestAdapter.ts b/src/client/testing/testController/workspaceTestAdapter.ts index d8d6cb53d835..a73acdaba5f0 100644 --- a/src/client/testing/testController/workspaceTestAdapter.ts +++ b/src/client/testing/testController/workspaceTestAdapter.ts @@ -134,7 +134,7 @@ export class WorkspaceTestAdapter { try { // ** execution factory only defined for new rewrite way if (executionFactory !== undefined) { - await this.discoveryAdapter.discoverTests(this.workspaceUri, executionFactory, interpreter); + await this.discoveryAdapter.discoverTests(this.workspaceUri, executionFactory, token, interpreter); } else { await this.discoveryAdapter.discoverTests(this.workspaceUri); } diff --git a/src/test/testing/testController/pytest/pytestDiscoveryAdapter.unit.test.ts b/src/test/testing/testController/pytest/pytestDiscoveryAdapter.unit.test.ts index 538b77161483..852942715270 100644 --- a/src/test/testing/testController/pytest/pytestDiscoveryAdapter.unit.test.ts +++ b/src/test/testing/testController/pytest/pytestDiscoveryAdapter.unit.test.ts @@ -2,7 +2,7 @@ // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. import * as assert from 'assert'; -import { Uri } from 'vscode'; +import { Uri, CancellationTokenSource } from 'vscode'; import * as typeMoq from 'typemoq'; import * as path from 'path'; import { Observable } from 'rxjs/Observable'; @@ -13,6 +13,7 @@ import { PytestTestDiscoveryAdapter } from '../../../../client/testing/testContr import { IPythonExecutionFactory, IPythonExecutionService, + // eslint-disable-next-line @typescript-eslint/no-unused-vars SpawnOptions, Output, } from '../../../../client/common/process/types'; @@ -31,11 +32,13 @@ suite('pytest test discovery adapter', () => { let outputChannel: typeMoq.IMock; let expectedPath: string; let uri: Uri; + // eslint-disable-next-line @typescript-eslint/no-unused-vars let expectedExtraVariables: Record; let mockProc: MockChildProcess; let deferred2: Deferred; let utilsStartDiscoveryNamedPipeStub: sinon.SinonStub; let useEnvExtensionStub: sinon.SinonStub; + let cancellationTokenSource: CancellationTokenSource; setup(() => { useEnvExtensionStub = sinon.stub(extapi, 'useEnvExtension'); @@ -86,9 +89,12 @@ suite('pytest test discovery adapter', () => { }, }; }); + + cancellationTokenSource = new CancellationTokenSource(); }); teardown(() => { sinon.restore(); + cancellationTokenSource.dispose(); }); test('Discovery should call exec with correct basic args', async () => { // set up exec mock @@ -333,4 +339,77 @@ suite('pytest test discovery adapter', () => { typeMoq.Times.once(), ); }); + test('Test discovery canceled before exec observable call finishes', async () => { + // set up exec mock + execFactory = typeMoq.Mock.ofType(); + execFactory + .setup((x) => x.createActivatedEnvironment(typeMoq.It.isAny())) + .returns(() => Promise.resolve(execService.object)); + + sinon.stub(fs.promises, 'lstat').callsFake( + async () => + ({ + isFile: () => true, + isSymbolicLink: () => false, + } as fs.Stats), + ); + sinon.stub(fs.promises, 'realpath').callsFake(async (pathEntered) => pathEntered.toString()); + + adapter = new PytestTestDiscoveryAdapter(configService, outputChannel.object); + const discoveryPromise = adapter.discoverTests(uri, execFactory.object, cancellationTokenSource.token); + + // Trigger cancellation before exec observable call finishes + cancellationTokenSource.cancel(); + + await discoveryPromise; + + assert.ok( + true, + 'Test resolves correctly when triggering a cancellation token immediately after starting discovery.', + ); + }); + + test('Test discovery cancelled while exec observable is running and proc is closed', async () => { + // + const execService2 = typeMoq.Mock.ofType(); + execService2.setup((p) => ((p as unknown) as any).then).returns(() => undefined); + execService2 + .setup((x) => x.execObservable(typeMoq.It.isAny(), typeMoq.It.isAny())) + .returns(() => { + // Trigger cancellation while exec observable is running + cancellationTokenSource.cancel(); + return { + proc: mockProc as any, + out: new Observable>(), + dispose: () => { + /* no-body */ + }, + }; + }); + // set up exec mock + deferred = createDeferred(); + execFactory = typeMoq.Mock.ofType(); + execFactory + .setup((x) => x.createActivatedEnvironment(typeMoq.It.isAny())) + .returns(() => { + deferred.resolve(); + return Promise.resolve(execService2.object); + }); + + sinon.stub(fs.promises, 'lstat').callsFake( + async () => + ({ + isFile: () => true, + isSymbolicLink: () => false, + } as fs.Stats), + ); + sinon.stub(fs.promises, 'realpath').callsFake(async (pathEntered) => pathEntered.toString()); + + adapter = new PytestTestDiscoveryAdapter(configService, outputChannel.object); + const discoveryPromise = adapter.discoverTests(uri, execFactory.object, cancellationTokenSource.token); + + // add in await and trigger + await discoveryPromise; + assert.ok(true, 'Test resolves correctly when triggering a cancellation token in exec observable.'); + }); }); diff --git a/src/test/testing/testController/unittest/testDiscoveryAdapter.unit.test.ts b/src/test/testing/testController/unittest/testDiscoveryAdapter.unit.test.ts index a0ee65d57922..911a5f89afb4 100644 --- a/src/test/testing/testController/unittest/testDiscoveryAdapter.unit.test.ts +++ b/src/test/testing/testController/unittest/testDiscoveryAdapter.unit.test.ts @@ -4,8 +4,9 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import * as assert from 'assert'; import * as path from 'path'; -import * as typemoq from 'typemoq'; -import { Uri } from 'vscode'; +import * as typeMoq from 'typemoq'; +import * as fs from 'fs'; +import { CancellationTokenSource, Uri } from 'vscode'; import { Observable } from 'rxjs'; import * as sinon from 'sinon'; import { IConfigurationService, ITestOutputChannel } from '../../../../client/common/types'; @@ -23,38 +24,39 @@ import { import * as extapi from '../../../../client/envExt/api.internal'; suite('Unittest test discovery adapter', () => { - let stubConfigSettings: IConfigurationService; - let outputChannel: typemoq.IMock; + let configService: IConfigurationService; + let outputChannel: typeMoq.IMock; let mockProc: MockChildProcess; - let execService: typemoq.IMock; - let execFactory = typemoq.Mock.ofType(); + let execService: typeMoq.IMock; + let execFactory = typeMoq.Mock.ofType(); let deferred: Deferred; let expectedExtraVariables: Record; let expectedPath: string; let uri: Uri; let utilsStartDiscoveryNamedPipeStub: sinon.SinonStub; let useEnvExtensionStub: sinon.SinonStub; + let cancellationTokenSource: CancellationTokenSource; setup(() => { useEnvExtensionStub = sinon.stub(extapi, 'useEnvExtension'); useEnvExtensionStub.returns(false); expectedPath = path.join('/', 'new', 'cwd'); - stubConfigSettings = ({ + configService = ({ getSettings: () => ({ testing: { unittestArgs: ['-v', '-s', '.', '-p', 'test*'] }, }), } as unknown) as IConfigurationService; - outputChannel = typemoq.Mock.ofType(); + outputChannel = typeMoq.Mock.ofType(); // set up exec service with child process mockProc = new MockChildProcess('', ['']); const output = new Observable>(() => { /* no op */ }); - execService = typemoq.Mock.ofType(); + execService = typeMoq.Mock.ofType(); execService - .setup((x) => x.execObservable(typemoq.It.isAny(), typemoq.It.isAny())) + .setup((x) => x.execObservable(typeMoq.It.isAny(), typeMoq.It.isAny())) .returns(() => { deferred.resolve(); console.log('execObservable is returning'); @@ -66,10 +68,10 @@ suite('Unittest test discovery adapter', () => { }, }; }); - execFactory = typemoq.Mock.ofType(); + execFactory = typeMoq.Mock.ofType(); deferred = createDeferred(); execFactory - .setup((x) => x.createActivatedEnvironment(typemoq.It.isAny())) + .setup((x) => x.createActivatedEnvironment(typeMoq.It.isAny())) .returns(() => Promise.resolve(execService.object)); execFactory.setup((p) => ((p as unknown) as any).then).returns(() => undefined); execService.setup((p) => ((p as unknown) as any).then).returns(() => undefined); @@ -83,13 +85,15 @@ suite('Unittest test discovery adapter', () => { utilsStartDiscoveryNamedPipeStub = sinon.stub(util, 'startDiscoveryNamedPipe'); utilsStartDiscoveryNamedPipeStub.callsFake(() => Promise.resolve('discoveryResultPipe-mockName')); + cancellationTokenSource = new CancellationTokenSource(); }); teardown(() => { sinon.restore(); + cancellationTokenSource.dispose(); }); test('DiscoverTests should send the discovery command to the test server with the correct args', async () => { - const adapter = new UnittestTestDiscoveryAdapter(stubConfigSettings, outputChannel.object); + const adapter = new UnittestTestDiscoveryAdapter(configService, outputChannel.object); adapter.discoverTests(uri, execFactory.object); const script = path.join(EXTENSION_ROOT_DIR, 'python_files', 'unittestadapter', 'discovery.py'); const argsExpected = [script, '--udiscovery', '-v', '-s', '.', '-p', 'test*']; @@ -100,7 +104,7 @@ suite('Unittest test discovery adapter', () => { execService.verify( (x) => x.execObservable( - typemoq.It.is>((argsActual) => { + typeMoq.It.is>((argsActual) => { try { assert.equal(argsActual.length, argsExpected.length); assert.deepEqual(argsActual, argsExpected); @@ -110,7 +114,7 @@ suite('Unittest test discovery adapter', () => { throw e; } }), - typemoq.It.is((options) => { + typeMoq.It.is((options) => { try { assert.deepEqual(options.env, expectedExtraVariables); assert.equal(options.cwd, expectedPath); @@ -122,17 +126,17 @@ suite('Unittest test discovery adapter', () => { } }), ), - typemoq.Times.once(), + typeMoq.Times.once(), ); }); test('DiscoverTests should respect settings.testings.cwd when present', async () => { const expectedNewPath = path.join('/', 'new', 'cwd'); - stubConfigSettings = ({ + configService = ({ getSettings: () => ({ testing: { unittestArgs: ['-v', '-s', '.', '-p', 'test*'], cwd: expectedNewPath.toString() }, }), } as unknown) as IConfigurationService; - const adapter = new UnittestTestDiscoveryAdapter(stubConfigSettings, outputChannel.object); + const adapter = new UnittestTestDiscoveryAdapter(configService, outputChannel.object); adapter.discoverTests(uri, execFactory.object); const script = path.join(EXTENSION_ROOT_DIR, 'python_files', 'unittestadapter', 'discovery.py'); const argsExpected = [script, '--udiscovery', '-v', '-s', '.', '-p', 'test*']; @@ -143,7 +147,7 @@ suite('Unittest test discovery adapter', () => { execService.verify( (x) => x.execObservable( - typemoq.It.is>((argsActual) => { + typeMoq.It.is>((argsActual) => { try { assert.equal(argsActual.length, argsExpected.length); assert.deepEqual(argsActual, argsExpected); @@ -153,7 +157,7 @@ suite('Unittest test discovery adapter', () => { throw e; } }), - typemoq.It.is((options) => { + typeMoq.It.is((options) => { try { assert.deepEqual(options.env, expectedExtraVariables); assert.equal(options.cwd, expectedNewPath); @@ -165,7 +169,80 @@ suite('Unittest test discovery adapter', () => { } }), ), - typemoq.Times.once(), + typeMoq.Times.once(), ); }); + test('Test discovery canceled before exec observable call finishes', async () => { + // set up exec mock + execFactory = typeMoq.Mock.ofType(); + execFactory + .setup((x) => x.createActivatedEnvironment(typeMoq.It.isAny())) + .returns(() => Promise.resolve(execService.object)); + + sinon.stub(fs.promises, 'lstat').callsFake( + async () => + ({ + isFile: () => true, + isSymbolicLink: () => false, + } as fs.Stats), + ); + sinon.stub(fs.promises, 'realpath').callsFake(async (pathEntered) => pathEntered.toString()); + + const adapter = new UnittestTestDiscoveryAdapter(configService, outputChannel.object); + const discoveryPromise = adapter.discoverTests(uri, execFactory.object, cancellationTokenSource.token); + + // Trigger cancellation before exec observable call finishes + cancellationTokenSource.cancel(); + + await discoveryPromise; + + assert.ok( + true, + 'Test resolves correctly when triggering a cancellation token immediately after starting discovery.', + ); + }); + + test('Test discovery cancelled while exec observable is running and proc is closed', async () => { + // + const execService2 = typeMoq.Mock.ofType(); + execService2.setup((p) => ((p as unknown) as any).then).returns(() => undefined); + execService2 + .setup((x) => x.execObservable(typeMoq.It.isAny(), typeMoq.It.isAny())) + .returns(() => { + // Trigger cancellation while exec observable is running + cancellationTokenSource.cancel(); + return { + proc: mockProc as any, + out: new Observable>(), + dispose: () => { + /* no-body */ + }, + }; + }); + // set up exec mock + deferred = createDeferred(); + execFactory = typeMoq.Mock.ofType(); + execFactory + .setup((x) => x.createActivatedEnvironment(typeMoq.It.isAny())) + .returns(() => { + deferred.resolve(); + return Promise.resolve(execService2.object); + }); + + sinon.stub(fs.promises, 'lstat').callsFake( + async () => + ({ + isFile: () => true, + isSymbolicLink: () => false, + } as fs.Stats), + ); + sinon.stub(fs.promises, 'realpath').callsFake(async (pathEntered) => pathEntered.toString()); + + const adapter = new UnittestTestDiscoveryAdapter(configService, outputChannel.object); + const discoveryPromise = adapter.discoverTests(uri, execFactory.object, cancellationTokenSource.token); + + // add in await and trigger + await discoveryPromise; + assert.ok(true, 'Test resolves correctly when triggering a cancellation token in exec observable.'); + }); });