Skip to content

Commit 540cb99

Browse files
committed
Reregister testproj databases around test runs
To deal with the problem of CodeQL tests modifying open testproj databases, this commit removes open databases from the extension prior to running tests, and tries to open those databases again after tests finish running.
1 parent 3abc8df commit 540cb99

File tree

6 files changed

+227
-12
lines changed

6 files changed

+227
-12
lines changed

extensions/ql-vscode/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
- Display CodeQL CLI version being downloaded during an upgrade. [#862](https://github.com/github/vscode-codeql/pull/862)
66
- Display a helpful message and link to documentation when a query produces no results. [#866](https://github.com/github/vscode-codeql/pull/866)
7+
- Refresh test databases automatically after a test run. [#868](https://github.com/github/vscode-codeql/pull/868)
78

89
## 1.4.8 - 05 May 2021
910

extensions/ql-vscode/src/databases.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -265,6 +265,11 @@ export interface DatabaseItem {
265265
*/
266266
belongsToSourceArchiveExplorerUri(uri: vscode.Uri): boolean;
267267

268+
/**
269+
* Whether the database may be affected by test execution for the given path.
270+
*/
271+
isAffectedByTest(testPath: string): Promise<boolean>;
272+
268273
/**
269274
* Gets the state of this database, to be persisted in the workspace state.
270275
*/
@@ -470,6 +475,27 @@ export class DatabaseItemImpl implements DatabaseItem {
470475
return uri.scheme === zipArchiveScheme &&
471476
decodeSourceArchiveUri(uri).sourceArchiveZipPath === this.sourceArchive.fsPath;
472477
}
478+
479+
public async isAffectedByTest(testPath: string): Promise<boolean> {
480+
const databasePath = this.databaseUri.fsPath;
481+
if (!databasePath.endsWith('.testproj')) {
482+
return false;
483+
}
484+
try {
485+
const stats = await fs.stat(testPath);
486+
if (stats.isDirectory()) {
487+
return !path.relative(testPath, databasePath).startsWith('..');
488+
} else {
489+
// database for /one/two/three/test.ql is at /one/two/three/three.testproj
490+
const testdir = path.dirname(testPath);
491+
const testdirbase = path.basename(testdir);
492+
return databasePath == path.join(testdir, testdirbase + '.testproj');
493+
}
494+
} catch {
495+
// No information available for test path - assume database is unaffected.
496+
return false;
497+
}
498+
}
473499
}
474500

475501
/**

extensions/ql-vscode/src/extension.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -526,7 +526,7 @@ async function activateWithInstalledDistribution(
526526
);
527527
if (testExplorerExtension) {
528528
const testHub = testExplorerExtension.exports;
529-
const testAdapterFactory = new QLTestAdapterFactory(testHub, cliServer);
529+
const testAdapterFactory = new QLTestAdapterFactory(testHub, cliServer, dbm);
530530
ctx.subscriptions.push(testAdapterFactory);
531531

532532
const testUIService = new TestUIService(testHub);

extensions/ql-vscode/src/test-adapter.ts

Lines changed: 71 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import * as fs from 'fs-extra';
12
import * as path from 'path';
23
import * as vscode from 'vscode';
34
import {
@@ -17,8 +18,9 @@ import { QLTestFile, QLTestNode, QLTestDirectory, QLTestDiscovery } from './qlte
1718
import { Event, EventEmitter, CancellationTokenSource, CancellationToken } from 'vscode';
1819
import { DisposableObject } from './pure/disposable-object';
1920
import { CodeQLCliServer } from './cli';
20-
import { getOnDiskWorkspaceFolders } from './helpers';
21+
import { getOnDiskWorkspaceFolders, showAndLogErrorMessage, showAndLogWarningMessage } from './helpers';
2122
import { testLogger } from './logging';
23+
import { DatabaseItem, DatabaseManager } from './databases';
2224

2325
/**
2426
* Get the full path of the `.expected` file for the specified QL test.
@@ -57,13 +59,13 @@ function getTestOutputFile(testPath: string, extension: string): string {
5759
* A factory service that creates `QLTestAdapter` objects for workspace folders on demand.
5860
*/
5961
export class QLTestAdapterFactory extends DisposableObject {
60-
constructor(testHub: TestHub, cliServer: CodeQLCliServer) {
62+
constructor(testHub: TestHub, cliServer: CodeQLCliServer, databaseManager: DatabaseManager) {
6163
super();
6264

6365
// this will register a QLTestAdapter for each WorkspaceFolder
6466
this.push(new TestAdapterRegistrar(
6567
testHub,
66-
workspaceFolder => new QLTestAdapter(workspaceFolder, cliServer)
68+
workspaceFolder => new QLTestAdapter(workspaceFolder, cliServer, databaseManager)
6769
));
6870
}
6971
}
@@ -91,7 +93,8 @@ export class QLTestAdapter extends DisposableObject implements TestAdapter {
9193

9294
constructor(
9395
public readonly workspaceFolder: vscode.WorkspaceFolder,
94-
private readonly cliServer: CodeQLCliServer
96+
private readonly cliServer: CodeQLCliServer,
97+
private readonly databaseManager: DatabaseManager
9598
) {
9699
super();
97100

@@ -182,19 +185,79 @@ export class QLTestAdapter extends DisposableObject implements TestAdapter {
182185
testLogger.outputChannel.show(true);
183186

184187
this.runningTask = this.track(new CancellationTokenSource());
188+
const token = this.runningTask.token;
185189

186190
this._testStates.fire({ type: 'started', tests: tests } as TestRunStartedEvent);
187191

192+
const currentDatabaseUri = this.databaseManager.currentDatabaseItem?.databaseUri;
193+
const databasesUnderTest = this.databaseManager.databaseItems
194+
.filter(database => tests.find(testPath => database.isAffectedByTest(testPath)));
195+
196+
await this.removeDatabasesBeforeTests(databasesUnderTest, token);
188197
try {
189-
await this.runTests(tests, this.runningTask.token);
190-
}
191-
catch (e) {
192-
/**/
198+
await this.runTests(tests, token);
199+
} catch (e) {
200+
// CodeQL testing can throw exception even in normal scenarios. For example, if the test run
201+
// produces no output (which is normal), the testing command would throw an exception on
202+
// unexpected EOF during json parsing. So nothing needs to be done here - all the relevant
203+
// error information (if any) should have already been written to the test logger.
193204
}
205+
await this.reopenDatabasesAfterTests(databasesUnderTest, currentDatabaseUri, token);
206+
194207
this._testStates.fire({ type: 'finished' } as TestRunFinishedEvent);
195208
this.clearTask();
196209
}
197210

211+
private async removeDatabasesBeforeTests(
212+
databasesUnderTest: DatabaseItem[], token: vscode.CancellationToken): Promise<void> {
213+
for (const database of databasesUnderTest) {
214+
try {
215+
await this.databaseManager
216+
.removeDatabaseItem(_ => { /* no progress reporting */ }, token, database);
217+
} catch (e) {
218+
// This method is invoked from Test Explorer UI, and testing indicates that Test
219+
// Explorer UI swallows any thrown exception without reporting it to the user.
220+
// So we need to display the error message ourselves and then rethrow.
221+
showAndLogErrorMessage(`Cannot remove database ${database.name}: ${e}`);
222+
throw e;
223+
}
224+
}
225+
}
226+
227+
private async reopenDatabasesAfterTests(
228+
databasesUnderTest: DatabaseItem[],
229+
currentDatabaseUri: vscode.Uri | undefined,
230+
token: vscode.CancellationToken): Promise<void> {
231+
for (const closedDatabase of databasesUnderTest) {
232+
const uri = closedDatabase.databaseUri;
233+
if (await this.isFileAccessible(uri)) {
234+
try {
235+
const reopenedDatabase = await this.databaseManager
236+
.openDatabase(_ => { /* no progress reporting */ }, token, uri);
237+
await this.databaseManager.renameDatabaseItem(reopenedDatabase, closedDatabase.name);
238+
if (currentDatabaseUri == uri) {
239+
await this.databaseManager.setCurrentDatabaseItem(reopenedDatabase, true);
240+
}
241+
} catch (e) {
242+
// This method is invoked from Test Explorer UI, and testing indicates that Test
243+
// Explorer UI swallows any thrown exception without reporting it to the user.
244+
// So we need to display the error message ourselves and then rethrow.
245+
showAndLogWarningMessage(`Cannot reopen database ${uri}: ${e}`);
246+
throw e;
247+
}
248+
}
249+
}
250+
}
251+
252+
private async isFileAccessible(uri: vscode.Uri): Promise<boolean> {
253+
try {
254+
await fs.access(uri.fsPath);
255+
return true;
256+
} catch {
257+
return false;
258+
}
259+
}
260+
198261
private clearTask(): void {
199262
if (this.runningTask !== undefined) {
200263
const runningTask = this.runningTask;

extensions/ql-vscode/src/vscode-tests/minimal-workspace/databases.test.ts

Lines changed: 67 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -179,7 +179,7 @@ describe('databases', () => {
179179
expect(spy).to.have.been.calledWith(mockEvent);
180180
});
181181

182-
it('should add a database item source archive', async function() {
182+
it('should add a database item source archive', async function () {
183183
const mockDbItem = createMockDB();
184184
mockDbItem.name = 'xxx';
185185
await (databaseManager as any).addDatabaseSourceArchiveFolder(mockDbItem);
@@ -414,6 +414,72 @@ describe('databases', () => {
414414
expect(result).to.eq('');
415415
});
416416

417+
describe('isAffectedByTest', () => {
418+
const directoryStats = new fs.Stats();
419+
const fileStats = new fs.Stats();
420+
421+
before(() => {
422+
sinon.stub(directoryStats, 'isDirectory').returns(true);
423+
sinon.stub(fileStats, 'isDirectory').returns(false);
424+
});
425+
426+
it('should return true for testproj database in test directory', async () => {
427+
sandbox.stub(fs, 'stat').resolves(directoryStats);
428+
const db = createMockDB(sourceLocationUri(), Uri.file('/path/to/dir/dir.testproj'));
429+
expect(await db.isAffectedByTest('/path/to/dir')).to.true;
430+
});
431+
432+
it('should return false for non-existent test directory', async () => {
433+
sandbox.stub(fs, 'stat').throws('Simulated Error: ENOENT');
434+
const db = createMockDB(sourceLocationUri(), Uri.file('/path/to/dir/dir.testproj'));
435+
expect(await db.isAffectedByTest('/path/to/dir')).to.false;
436+
});
437+
438+
it('should return false for non-testproj database in test directory', async () => {
439+
sandbox.stub(fs, 'stat').resolves(directoryStats);
440+
const db = createMockDB(sourceLocationUri(), Uri.file('/path/to/dir/dir.proj'));
441+
expect(await db.isAffectedByTest('/path/to/dir')).to.false;
442+
});
443+
444+
it('should return false for testproj database outside test directory', async () => {
445+
sandbox.stub(fs, 'stat').resolves(directoryStats);
446+
const db = createMockDB(sourceLocationUri(), Uri.file('/path/to/other/dir.testproj'));
447+
expect(await db.isAffectedByTest('/path/to/dir')).to.false;
448+
});
449+
450+
it('should return false for testproj database for prefix directory', async () => {
451+
sandbox.stub(fs, 'stat').resolves(directoryStats);
452+
const db = createMockDB(sourceLocationUri(), Uri.file('/path/to/dir/dir.testproj'));
453+
// /path/to/d is a prefix of /path/to/dir/dir.testproj, but
454+
// /path/to/dir/dir.testproj is not under /path/to/d
455+
expect(await db.isAffectedByTest('/path/to/d')).to.false;
456+
});
457+
458+
it('should return true for testproj database for test file', async () => {
459+
sandbox.stub(fs, 'stat').resolves(fileStats);
460+
const db = createMockDB(sourceLocationUri(), Uri.file('/path/to/dir/dir.testproj'));
461+
expect(await db.isAffectedByTest('/path/to/dir/test.ql')).to.true;
462+
});
463+
464+
it('should return false for non-existent test file', async () => {
465+
sandbox.stub(fs, 'stat').throws('Simulated Error: ENOENT');
466+
const db = createMockDB(sourceLocationUri(), Uri.file('/path/to/dir/dir.testproj'));
467+
expect(await db.isAffectedByTest('/path/to/dir/test.ql')).to.false;
468+
});
469+
470+
it('should return false for non-testproj database for test file', async () => {
471+
sandbox.stub(fs, 'stat').resolves(fileStats);
472+
const db = createMockDB(sourceLocationUri(), Uri.file('/path/to/dir/dir.proj'));
473+
expect(await db.isAffectedByTest('/path/to/dir/test.ql')).to.false;
474+
});
475+
476+
it('should return false for testproj database not matching test file', async () => {
477+
sandbox.stub(fs, 'stat').resolves(fileStats);
478+
const db = createMockDB(sourceLocationUri(), Uri.file('/path/to/dir/dir.testproj'));
479+
expect(await db.isAffectedByTest('/path/to/test.ql')).to.false;
480+
});
481+
});
482+
417483
function createMockDB(
418484
// source archive location must be a real(-ish) location since
419485
// tests will add this to the workspace location

extensions/ql-vscode/src/vscode-tests/no-workspace/test-adapter.test.ts

Lines changed: 61 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,32 +1,70 @@
11
import 'vscode-test';
22
import 'mocha';
33
import * as sinon from 'sinon';
4+
import * as fs from 'fs-extra';
45
import { Uri, WorkspaceFolder } from 'vscode';
56
import { expect } from 'chai';
67

78
import { QLTestAdapter } from '../../test-adapter';
89
import { CodeQLCliServer } from '../../cli';
10+
import { DatabaseItem, DatabaseItemImpl, DatabaseManager, FullDatabaseOptions } from '../../databases';
911

1012
describe('test-adapter', () => {
1113
let adapter: QLTestAdapter;
14+
let fakeDatabaseManager: DatabaseManager;
15+
let currentDatabaseItem: DatabaseItem | undefined;
16+
let databaseItems: DatabaseItem[] = [];
17+
let openDatabaseSpy: sinon.SinonStub;
18+
let removeDatabaseItemSpy: sinon.SinonStub;
19+
let renameDatabaseItemSpy: sinon.SinonStub;
20+
let setCurrentDatabaseItemSpy: sinon.SinonStub;
1221
let runTestsSpy: sinon.SinonStub;
1322
let resolveTestsSpy: sinon.SinonStub;
1423
let resolveQlpacksSpy: sinon.SinonStub;
1524
let sandox: sinon.SinonSandbox;
1625

26+
const preTestDatabaseItem = new DatabaseItemImpl(
27+
Uri.file('/path/to/test/dir/dir.testproj'),
28+
undefined,
29+
{ displayName: 'custom display name' } as unknown as FullDatabaseOptions,
30+
(_) => { /* no change event listener */ }
31+
);
32+
const postTestDatabaseItem = new DatabaseItemImpl(
33+
Uri.file('/path/to/test/dir/dir.testproj'),
34+
undefined,
35+
{ displayName: 'default name' } as unknown as FullDatabaseOptions,
36+
(_) => { /* no change event listener */ }
37+
);
38+
1739
beforeEach(() => {
1840
sandox = sinon.createSandbox();
1941
mockRunTests();
42+
openDatabaseSpy = sandox.stub().resolves(postTestDatabaseItem);
43+
removeDatabaseItemSpy = sandox.stub().resolves();
44+
renameDatabaseItemSpy = sandox.stub().resolves();
45+
setCurrentDatabaseItemSpy = sandox.stub().resolves();
2046
resolveQlpacksSpy = sandox.stub().resolves({});
2147
resolveTestsSpy = sandox.stub().resolves([]);
48+
fakeDatabaseManager = {
49+
currentDatabaseItem: undefined,
50+
databaseItems: undefined,
51+
openDatabase: openDatabaseSpy,
52+
removeDatabaseItem: removeDatabaseItemSpy,
53+
renameDatabaseItem: renameDatabaseItemSpy,
54+
setCurrentDatabaseItem: setCurrentDatabaseItemSpy,
55+
} as unknown as DatabaseManager;
56+
sandox.stub(fakeDatabaseManager, 'currentDatabaseItem').get(() => currentDatabaseItem);
57+
sandox.stub(fakeDatabaseManager, 'databaseItems').get(() => databaseItems);
58+
sandox.stub(preTestDatabaseItem, 'isAffectedByTest').resolves(true);
2259
adapter = new QLTestAdapter({
2360
name: 'ABC',
2461
uri: Uri.parse('file:/ab/c')
2562
} as WorkspaceFolder, {
2663
runTests: runTestsSpy,
2764
resolveQlpacks: resolveQlpacksSpy,
2865
resolveTests: resolveTestsSpy
29-
} as unknown as CodeQLCliServer);
66+
} as unknown as CodeQLCliServer,
67+
fakeDatabaseManager);
3068
});
3169

3270
afterEach(() => {
@@ -74,12 +112,33 @@ describe('test-adapter', () => {
74112
expect(listenerSpy).to.have.callCount(5);
75113
});
76114

115+
it('should reregister testproj databases around test run', async () => {
116+
sandox.stub(fs, 'access').resolves();
117+
currentDatabaseItem = preTestDatabaseItem;
118+
databaseItems = [preTestDatabaseItem];
119+
await adapter.run(['/path/to/test/dir']);
120+
121+
removeDatabaseItemSpy.getCall(0).calledBefore(runTestsSpy.getCall(0));
122+
openDatabaseSpy.getCall(0).calledAfter(runTestsSpy.getCall(0));
123+
renameDatabaseItemSpy.getCall(0).calledAfter(openDatabaseSpy.getCall(0));
124+
setCurrentDatabaseItemSpy.getCall(0).calledAfter(openDatabaseSpy.getCall(0));
125+
126+
sinon.assert.calledOnceWithExactly(
127+
removeDatabaseItemSpy, sinon.match.any, sinon.match.any, preTestDatabaseItem);
128+
sinon.assert.calledOnceWithExactly(
129+
openDatabaseSpy, sinon.match.any, sinon.match.any, preTestDatabaseItem.databaseUri);
130+
sinon.assert.calledOnceWithExactly(
131+
renameDatabaseItemSpy, postTestDatabaseItem, preTestDatabaseItem.name);
132+
sinon.assert.calledOnceWithExactly(
133+
setCurrentDatabaseItemSpy, postTestDatabaseItem, true);
134+
});
135+
77136
function mockRunTests() {
78137
// runTests is an async generator function. This is not directly supported in sinon
79138
// However, we can pretend the same thing by just returning an async array.
80139
runTestsSpy = sandox.stub();
81140
runTestsSpy.returns(
82-
(async function*() {
141+
(async function* () {
83142
yield Promise.resolve({
84143
test: Uri.parse('file:/ab/c/d.ql').fsPath,
85144
pass: true,

0 commit comments

Comments
 (0)