Skip to content

Commit 354fcd6

Browse files
committed
fix promise returns and add tests for discovery cancellation
1 parent b15fbba commit 354fcd6

File tree

5 files changed

+200
-53
lines changed

5 files changed

+200
-53
lines changed

src/client/testing/testController/pytest/pytestDiscoveryAdapter.ts

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import {
1010
SpawnOptions,
1111
} from '../../../common/process/types';
1212
import { IConfigurationService, ITestOutputChannel } from '../../../common/types';
13-
import { Deferred } from '../../../common/utils/async';
13+
import { createDeferred, Deferred } from '../../../common/utils/async';
1414
import { EXTENSION_ROOT_DIR } from '../../../constants';
1515
import { traceError, traceInfo, traceVerbose, traceWarn } from '../../../logging';
1616
import { DiscoveredTestPayload, ITestDiscoveryAdapter, ITestResultResolver } from '../common/types';
@@ -44,27 +44,27 @@ export class PytestTestDiscoveryAdapter implements ITestDiscoveryAdapter {
4444
token?: CancellationToken,
4545
interpreter?: PythonEnvironment,
4646
): Promise<DiscoveredTestPayload> {
47+
const cSource = new CancellationTokenSource();
48+
const deferredReturn = createDeferred<DiscoveredTestPayload>();
49+
4750
token?.onCancellationRequested(() => {
4851
traceInfo(`Test discovery cancelled.`);
49-
const discoveryPayload: DiscoveredTestPayload = { cwd: uri.fsPath, status: 'success' };
50-
return discoveryPayload;
52+
cSource.cancel();
53+
deferredReturn.resolve({ cwd: uri.fsPath, status: 'success' });
5154
});
5255

53-
const cSource = new CancellationTokenSource();
54-
token?.onCancellationRequested(() => cSource.cancel());
55-
5656
const name = await startDiscoveryNamedPipe((data: DiscoveredTestPayload) => {
5757
// if the token is cancelled, we don't want process the data
5858
if (!token?.isCancellationRequested) {
5959
this.resultResolver?.resolveDiscovery(data);
6060
}
6161
}, cSource.token);
6262

63-
await this.runPytestDiscovery(uri, name, cSource, executionFactory, interpreter, token);
63+
this.runPytestDiscovery(uri, name, cSource, executionFactory, interpreter, token).then(() => {
64+
deferredReturn.resolve({ cwd: uri.fsPath, status: 'success' });
65+
});
6466

65-
// this is only a placeholder to handle function overloading until rewrite is finished
66-
const discoveryPayload: DiscoveredTestPayload = { cwd: uri.fsPath, status: 'success' };
67-
return discoveryPayload;
67+
return deferredReturn.promise;
6868
}
6969

7070
async runPytestDiscovery(

src/client/testing/testController/pytest/pytestExecutionAdapter.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -244,7 +244,8 @@ export class PytestTestExecutionAdapter implements ITestExecutionAdapter {
244244
});
245245

246246
const result = execService?.execObservable(runArgs, spawnOptions);
247-
resultProc = result?.proc;
247+
248+
248249

249250
// Take all output from the subprocess and add it to the test output channel. This will be the pytest output.
250251
// Displays output to user and ensure the subprocess doesn't run into buffer overflow.

src/client/testing/testController/unittest/testDiscoveryAdapter.ts

Lines changed: 10 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -51,27 +51,21 @@ export class UnittestTestDiscoveryAdapter implements ITestDiscoveryAdapter {
5151
const { unittestArgs } = settings.testing;
5252
const cwd = settings.testing.cwd && settings.testing.cwd.length > 0 ? settings.testing.cwd : uri.fsPath;
5353

54+
const cSource = new CancellationTokenSource();
55+
const deferredReturn = createDeferred<DiscoveredTestPayload>();
56+
5457
token?.onCancellationRequested(() => {
5558
traceInfo(`Test discovery cancelled.`);
56-
const discoveryPayload: DiscoveredTestPayload = { cwd: uri.fsPath, status: 'success' };
57-
return discoveryPayload;
59+
cSource.cancel();
60+
deferredReturn.resolve({ cwd: uri.fsPath, status: 'success' });
5861
});
5962

60-
const cSource = new CancellationTokenSource();
61-
token?.onCancellationRequested(() => cSource.cancel());
62-
6363
const name = await startDiscoveryNamedPipe((data: DiscoveredTestPayload) => {
6464
if (!token?.isCancellationRequested) {
6565
this.resultResolver?.resolveDiscovery(data);
6666
}
6767
}, cSource.token);
6868

69-
token?.onCancellationRequested(() => {
70-
traceInfo(`Test discovery cancelled.`);
71-
const discoveryPayload: DiscoveredTestPayload = { cwd: uri.fsPath, status: 'success' };
72-
return discoveryPayload;
73-
});
74-
7569
// set up env with the pipe name
7670
let env: EnvironmentVariables | undefined = await this.envVarsService?.getEnvironmentVariables(uri);
7771
if (env === undefined) {
@@ -88,15 +82,11 @@ export class UnittestTestDiscoveryAdapter implements ITestDiscoveryAdapter {
8882
token,
8983
};
9084

91-
try {
92-
await this.runDiscovery(uri, options, name, cwd, cSource, executionFactory);
93-
} finally {
94-
// none
95-
}
96-
// placeholder until after the rewrite is adopted
97-
// TODO: remove after adoption.
98-
const discoveryPayload: DiscoveredTestPayload = { cwd, status: 'success' };
99-
return discoveryPayload;
85+
this.runDiscovery(uri, options, name, cwd, cSource, executionFactory).then(() => {
86+
deferredReturn.resolve({ cwd: uri.fsPath, status: 'success' });
87+
});
88+
89+
return deferredReturn.promise;
10090
}
10191

10292
async runDiscovery(

src/test/testing/testController/pytest/pytestDiscoveryAdapter.unit.test.ts

Lines changed: 80 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
// Copyright (c) Microsoft Corporation. All rights reserved.
33
// Licensed under the MIT License.
44
import * as assert from 'assert';
5-
import { Uri } from 'vscode';
5+
import { Uri, CancellationTokenSource } from 'vscode';
66
import * as typeMoq from 'typemoq';
77
import * as path from 'path';
88
import { Observable } from 'rxjs/Observable';
@@ -13,6 +13,7 @@ import { PytestTestDiscoveryAdapter } from '../../../../client/testing/testContr
1313
import {
1414
IPythonExecutionFactory,
1515
IPythonExecutionService,
16+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
1617
SpawnOptions,
1718
Output,
1819
} from '../../../../client/common/process/types';
@@ -31,11 +32,13 @@ suite('pytest test discovery adapter', () => {
3132
let outputChannel: typeMoq.IMock<ITestOutputChannel>;
3233
let expectedPath: string;
3334
let uri: Uri;
35+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
3436
let expectedExtraVariables: Record<string, string>;
3537
let mockProc: MockChildProcess;
3638
let deferred2: Deferred<void>;
3739
let utilsStartDiscoveryNamedPipeStub: sinon.SinonStub;
3840
let useEnvExtensionStub: sinon.SinonStub;
41+
let cancellationTokenSource: CancellationTokenSource;
3942

4043
setup(() => {
4144
useEnvExtensionStub = sinon.stub(extapi, 'useEnvExtension');
@@ -86,9 +89,12 @@ suite('pytest test discovery adapter', () => {
8689
},
8790
};
8891
});
92+
93+
cancellationTokenSource = new CancellationTokenSource();
8994
});
9095
teardown(() => {
9196
sinon.restore();
97+
cancellationTokenSource.dispose();
9298
});
9399
test('Discovery should call exec with correct basic args', async () => {
94100
// set up exec mock
@@ -333,4 +339,77 @@ suite('pytest test discovery adapter', () => {
333339
typeMoq.Times.once(),
334340
);
335341
});
342+
test('Test discovery canceled before exec observable call finishes', async () => {
343+
// set up exec mock
344+
execFactory = typeMoq.Mock.ofType<IPythonExecutionFactory>();
345+
execFactory
346+
.setup((x) => x.createActivatedEnvironment(typeMoq.It.isAny()))
347+
.returns(() => Promise.resolve(execService.object));
348+
349+
sinon.stub(fs.promises, 'lstat').callsFake(
350+
async () =>
351+
({
352+
isFile: () => true,
353+
isSymbolicLink: () => false,
354+
} as fs.Stats),
355+
);
356+
sinon.stub(fs.promises, 'realpath').callsFake(async (pathEntered) => pathEntered.toString());
357+
358+
adapter = new PytestTestDiscoveryAdapter(configService, outputChannel.object);
359+
const discoveryPromise = adapter.discoverTests(uri, execFactory.object, cancellationTokenSource.token);
360+
361+
// Trigger cancellation before exec observable call finishes
362+
cancellationTokenSource.cancel();
363+
364+
await discoveryPromise;
365+
366+
assert.ok(
367+
true,
368+
'Test resolves correctly when triggering a cancellation token immediately after starting discovery.',
369+
);
370+
});
371+
372+
test('Test discovery cancelled while exec observable is running and proc is closed', async () => {
373+
//
374+
const execService2 = typeMoq.Mock.ofType<IPythonExecutionService>();
375+
execService2.setup((p) => ((p as unknown) as any).then).returns(() => undefined);
376+
execService2
377+
.setup((x) => x.execObservable(typeMoq.It.isAny(), typeMoq.It.isAny()))
378+
.returns(() => {
379+
// Trigger cancellation while exec observable is running
380+
cancellationTokenSource.cancel();
381+
return {
382+
proc: mockProc as any,
383+
out: new Observable<Output<string>>(),
384+
dispose: () => {
385+
/* no-body */
386+
},
387+
};
388+
});
389+
// set up exec mock
390+
deferred = createDeferred();
391+
execFactory = typeMoq.Mock.ofType<IPythonExecutionFactory>();
392+
execFactory
393+
.setup((x) => x.createActivatedEnvironment(typeMoq.It.isAny()))
394+
.returns(() => {
395+
deferred.resolve();
396+
return Promise.resolve(execService2.object);
397+
});
398+
399+
sinon.stub(fs.promises, 'lstat').callsFake(
400+
async () =>
401+
({
402+
isFile: () => true,
403+
isSymbolicLink: () => false,
404+
} as fs.Stats),
405+
);
406+
sinon.stub(fs.promises, 'realpath').callsFake(async (pathEntered) => pathEntered.toString());
407+
408+
adapter = new PytestTestDiscoveryAdapter(configService, outputChannel.object);
409+
const discoveryPromise = adapter.discoverTests(uri, execFactory.object, cancellationTokenSource.token);
410+
411+
// add in await and trigger
412+
await discoveryPromise;
413+
assert.ok(true, 'Test resolves correctly when triggering a cancellation token in exec observable.');
414+
});
336415
});

0 commit comments

Comments
 (0)