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.
This commit is contained in:
Chuan-kai Lin
2021-05-20 15:22:00 -07:00
parent 3abc8df8fc
commit 540cb99de4
6 changed files with 227 additions and 12 deletions

View File

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

View File

@@ -265,6 +265,11 @@ export interface DatabaseItem {
*/
belongsToSourceArchiveExplorerUri(uri: vscode.Uri): boolean;
/**
* Whether the database may be affected by test execution for the given path.
*/
isAffectedByTest(testPath: string): Promise<boolean>;
/**
* Gets the state of this database, to be persisted in the workspace state.
*/
@@ -470,6 +475,27 @@ export class DatabaseItemImpl implements DatabaseItem {
return uri.scheme === zipArchiveScheme &&
decodeSourceArchiveUri(uri).sourceArchiveZipPath === this.sourceArchive.fsPath;
}
public async isAffectedByTest(testPath: string): Promise<boolean> {
const databasePath = this.databaseUri.fsPath;
if (!databasePath.endsWith('.testproj')) {
return false;
}
try {
const stats = await fs.stat(testPath);
if (stats.isDirectory()) {
return !path.relative(testPath, databasePath).startsWith('..');
} else {
// database for /one/two/three/test.ql is at /one/two/three/three.testproj
const testdir = path.dirname(testPath);
const testdirbase = path.basename(testdir);
return databasePath == path.join(testdir, testdirbase + '.testproj');
}
} catch {
// No information available for test path - assume database is unaffected.
return false;
}
}
}
/**

View File

@@ -526,7 +526,7 @@ async function activateWithInstalledDistribution(
);
if (testExplorerExtension) {
const testHub = testExplorerExtension.exports;
const testAdapterFactory = new QLTestAdapterFactory(testHub, cliServer);
const testAdapterFactory = new QLTestAdapterFactory(testHub, cliServer, dbm);
ctx.subscriptions.push(testAdapterFactory);
const testUIService = new TestUIService(testHub);

View File

@@ -1,3 +1,4 @@
import * as fs from 'fs-extra';
import * as path from 'path';
import * as vscode from 'vscode';
import {
@@ -17,8 +18,9 @@ import { QLTestFile, QLTestNode, QLTestDirectory, QLTestDiscovery } from './qlte
import { Event, EventEmitter, CancellationTokenSource, CancellationToken } from 'vscode';
import { DisposableObject } from './pure/disposable-object';
import { CodeQLCliServer } from './cli';
import { getOnDiskWorkspaceFolders } from './helpers';
import { getOnDiskWorkspaceFolders, showAndLogErrorMessage, showAndLogWarningMessage } from './helpers';
import { testLogger } from './logging';
import { DatabaseItem, DatabaseManager } from './databases';
/**
* Get the full path of the `.expected` file for the specified QL test.
@@ -57,13 +59,13 @@ function getTestOutputFile(testPath: string, extension: string): string {
* A factory service that creates `QLTestAdapter` objects for workspace folders on demand.
*/
export class QLTestAdapterFactory extends DisposableObject {
constructor(testHub: TestHub, cliServer: CodeQLCliServer) {
constructor(testHub: TestHub, cliServer: CodeQLCliServer, databaseManager: DatabaseManager) {
super();
// this will register a QLTestAdapter for each WorkspaceFolder
this.push(new TestAdapterRegistrar(
testHub,
workspaceFolder => new QLTestAdapter(workspaceFolder, cliServer)
workspaceFolder => new QLTestAdapter(workspaceFolder, cliServer, databaseManager)
));
}
}
@@ -91,7 +93,8 @@ export class QLTestAdapter extends DisposableObject implements TestAdapter {
constructor(
public readonly workspaceFolder: vscode.WorkspaceFolder,
private readonly cliServer: CodeQLCliServer
private readonly cliServer: CodeQLCliServer,
private readonly databaseManager: DatabaseManager
) {
super();
@@ -182,19 +185,79 @@ export class QLTestAdapter extends DisposableObject implements TestAdapter {
testLogger.outputChannel.show(true);
this.runningTask = this.track(new CancellationTokenSource());
const token = this.runningTask.token;
this._testStates.fire({ type: 'started', tests: tests } as TestRunStartedEvent);
const currentDatabaseUri = this.databaseManager.currentDatabaseItem?.databaseUri;
const databasesUnderTest = this.databaseManager.databaseItems
.filter(database => tests.find(testPath => database.isAffectedByTest(testPath)));
await this.removeDatabasesBeforeTests(databasesUnderTest, token);
try {
await this.runTests(tests, this.runningTask.token);
}
catch (e) {
/**/
await this.runTests(tests, token);
} catch (e) {
// CodeQL testing can throw exception even in normal scenarios. For example, if the test run
// produces no output (which is normal), the testing command would throw an exception on
// unexpected EOF during json parsing. So nothing needs to be done here - all the relevant
// error information (if any) should have already been written to the test logger.
}
await this.reopenDatabasesAfterTests(databasesUnderTest, currentDatabaseUri, token);
this._testStates.fire({ type: 'finished' } as TestRunFinishedEvent);
this.clearTask();
}
private async removeDatabasesBeforeTests(
databasesUnderTest: DatabaseItem[], token: vscode.CancellationToken): Promise<void> {
for (const database of databasesUnderTest) {
try {
await this.databaseManager
.removeDatabaseItem(_ => { /* no progress reporting */ }, token, database);
} catch (e) {
// This method is invoked from Test Explorer UI, and testing indicates that Test
// Explorer UI swallows any thrown exception without reporting it to the user.
// So we need to display the error message ourselves and then rethrow.
showAndLogErrorMessage(`Cannot remove database ${database.name}: ${e}`);
throw e;
}
}
}
private async reopenDatabasesAfterTests(
databasesUnderTest: DatabaseItem[],
currentDatabaseUri: vscode.Uri | undefined,
token: vscode.CancellationToken): Promise<void> {
for (const closedDatabase of databasesUnderTest) {
const uri = closedDatabase.databaseUri;
if (await this.isFileAccessible(uri)) {
try {
const reopenedDatabase = await this.databaseManager
.openDatabase(_ => { /* no progress reporting */ }, token, uri);
await this.databaseManager.renameDatabaseItem(reopenedDatabase, closedDatabase.name);
if (currentDatabaseUri == uri) {
await this.databaseManager.setCurrentDatabaseItem(reopenedDatabase, true);
}
} catch (e) {
// This method is invoked from Test Explorer UI, and testing indicates that Test
// Explorer UI swallows any thrown exception without reporting it to the user.
// So we need to display the error message ourselves and then rethrow.
showAndLogWarningMessage(`Cannot reopen database ${uri}: ${e}`);
throw e;
}
}
}
}
private async isFileAccessible(uri: vscode.Uri): Promise<boolean> {
try {
await fs.access(uri.fsPath);
return true;
} catch {
return false;
}
}
private clearTask(): void {
if (this.runningTask !== undefined) {
const runningTask = this.runningTask;

View File

@@ -179,7 +179,7 @@ describe('databases', () => {
expect(spy).to.have.been.calledWith(mockEvent);
});
it('should add a database item source archive', async function() {
it('should add a database item source archive', async function () {
const mockDbItem = createMockDB();
mockDbItem.name = 'xxx';
await (databaseManager as any).addDatabaseSourceArchiveFolder(mockDbItem);
@@ -414,6 +414,72 @@ describe('databases', () => {
expect(result).to.eq('');
});
describe('isAffectedByTest', () => {
const directoryStats = new fs.Stats();
const fileStats = new fs.Stats();
before(() => {
sinon.stub(directoryStats, 'isDirectory').returns(true);
sinon.stub(fileStats, 'isDirectory').returns(false);
});
it('should return true for testproj database in test directory', async () => {
sandbox.stub(fs, 'stat').resolves(directoryStats);
const db = createMockDB(sourceLocationUri(), Uri.file('/path/to/dir/dir.testproj'));
expect(await db.isAffectedByTest('/path/to/dir')).to.true;
});
it('should return false for non-existent test directory', async () => {
sandbox.stub(fs, 'stat').throws('Simulated Error: ENOENT');
const db = createMockDB(sourceLocationUri(), Uri.file('/path/to/dir/dir.testproj'));
expect(await db.isAffectedByTest('/path/to/dir')).to.false;
});
it('should return false for non-testproj database in test directory', async () => {
sandbox.stub(fs, 'stat').resolves(directoryStats);
const db = createMockDB(sourceLocationUri(), Uri.file('/path/to/dir/dir.proj'));
expect(await db.isAffectedByTest('/path/to/dir')).to.false;
});
it('should return false for testproj database outside test directory', async () => {
sandbox.stub(fs, 'stat').resolves(directoryStats);
const db = createMockDB(sourceLocationUri(), Uri.file('/path/to/other/dir.testproj'));
expect(await db.isAffectedByTest('/path/to/dir')).to.false;
});
it('should return false for testproj database for prefix directory', async () => {
sandbox.stub(fs, 'stat').resolves(directoryStats);
const db = createMockDB(sourceLocationUri(), Uri.file('/path/to/dir/dir.testproj'));
// /path/to/d is a prefix of /path/to/dir/dir.testproj, but
// /path/to/dir/dir.testproj is not under /path/to/d
expect(await db.isAffectedByTest('/path/to/d')).to.false;
});
it('should return true for testproj database for test file', async () => {
sandbox.stub(fs, 'stat').resolves(fileStats);
const db = createMockDB(sourceLocationUri(), Uri.file('/path/to/dir/dir.testproj'));
expect(await db.isAffectedByTest('/path/to/dir/test.ql')).to.true;
});
it('should return false for non-existent test file', async () => {
sandbox.stub(fs, 'stat').throws('Simulated Error: ENOENT');
const db = createMockDB(sourceLocationUri(), Uri.file('/path/to/dir/dir.testproj'));
expect(await db.isAffectedByTest('/path/to/dir/test.ql')).to.false;
});
it('should return false for non-testproj database for test file', async () => {
sandbox.stub(fs, 'stat').resolves(fileStats);
const db = createMockDB(sourceLocationUri(), Uri.file('/path/to/dir/dir.proj'));
expect(await db.isAffectedByTest('/path/to/dir/test.ql')).to.false;
});
it('should return false for testproj database not matching test file', async () => {
sandbox.stub(fs, 'stat').resolves(fileStats);
const db = createMockDB(sourceLocationUri(), Uri.file('/path/to/dir/dir.testproj'));
expect(await db.isAffectedByTest('/path/to/test.ql')).to.false;
});
});
function createMockDB(
// source archive location must be a real(-ish) location since
// tests will add this to the workspace location

View File

@@ -1,24 +1,61 @@
import 'vscode-test';
import 'mocha';
import * as sinon from 'sinon';
import * as fs from 'fs-extra';
import { Uri, WorkspaceFolder } from 'vscode';
import { expect } from 'chai';
import { QLTestAdapter } from '../../test-adapter';
import { CodeQLCliServer } from '../../cli';
import { DatabaseItem, DatabaseItemImpl, DatabaseManager, FullDatabaseOptions } from '../../databases';
describe('test-adapter', () => {
let adapter: QLTestAdapter;
let fakeDatabaseManager: DatabaseManager;
let currentDatabaseItem: DatabaseItem | undefined;
let databaseItems: DatabaseItem[] = [];
let openDatabaseSpy: sinon.SinonStub;
let removeDatabaseItemSpy: sinon.SinonStub;
let renameDatabaseItemSpy: sinon.SinonStub;
let setCurrentDatabaseItemSpy: sinon.SinonStub;
let runTestsSpy: sinon.SinonStub;
let resolveTestsSpy: sinon.SinonStub;
let resolveQlpacksSpy: sinon.SinonStub;
let sandox: sinon.SinonSandbox;
const preTestDatabaseItem = new DatabaseItemImpl(
Uri.file('/path/to/test/dir/dir.testproj'),
undefined,
{ displayName: 'custom display name' } as unknown as FullDatabaseOptions,
(_) => { /* no change event listener */ }
);
const postTestDatabaseItem = new DatabaseItemImpl(
Uri.file('/path/to/test/dir/dir.testproj'),
undefined,
{ displayName: 'default name' } as unknown as FullDatabaseOptions,
(_) => { /* no change event listener */ }
);
beforeEach(() => {
sandox = sinon.createSandbox();
mockRunTests();
openDatabaseSpy = sandox.stub().resolves(postTestDatabaseItem);
removeDatabaseItemSpy = sandox.stub().resolves();
renameDatabaseItemSpy = sandox.stub().resolves();
setCurrentDatabaseItemSpy = sandox.stub().resolves();
resolveQlpacksSpy = sandox.stub().resolves({});
resolveTestsSpy = sandox.stub().resolves([]);
fakeDatabaseManager = {
currentDatabaseItem: undefined,
databaseItems: undefined,
openDatabase: openDatabaseSpy,
removeDatabaseItem: removeDatabaseItemSpy,
renameDatabaseItem: renameDatabaseItemSpy,
setCurrentDatabaseItem: setCurrentDatabaseItemSpy,
} as unknown as DatabaseManager;
sandox.stub(fakeDatabaseManager, 'currentDatabaseItem').get(() => currentDatabaseItem);
sandox.stub(fakeDatabaseManager, 'databaseItems').get(() => databaseItems);
sandox.stub(preTestDatabaseItem, 'isAffectedByTest').resolves(true);
adapter = new QLTestAdapter({
name: 'ABC',
uri: Uri.parse('file:/ab/c')
@@ -26,7 +63,8 @@ describe('test-adapter', () => {
runTests: runTestsSpy,
resolveQlpacks: resolveQlpacksSpy,
resolveTests: resolveTestsSpy
} as unknown as CodeQLCliServer);
} as unknown as CodeQLCliServer,
fakeDatabaseManager);
});
afterEach(() => {
@@ -74,12 +112,33 @@ describe('test-adapter', () => {
expect(listenerSpy).to.have.callCount(5);
});
it('should reregister testproj databases around test run', async () => {
sandox.stub(fs, 'access').resolves();
currentDatabaseItem = preTestDatabaseItem;
databaseItems = [preTestDatabaseItem];
await adapter.run(['/path/to/test/dir']);
removeDatabaseItemSpy.getCall(0).calledBefore(runTestsSpy.getCall(0));
openDatabaseSpy.getCall(0).calledAfter(runTestsSpy.getCall(0));
renameDatabaseItemSpy.getCall(0).calledAfter(openDatabaseSpy.getCall(0));
setCurrentDatabaseItemSpy.getCall(0).calledAfter(openDatabaseSpy.getCall(0));
sinon.assert.calledOnceWithExactly(
removeDatabaseItemSpy, sinon.match.any, sinon.match.any, preTestDatabaseItem);
sinon.assert.calledOnceWithExactly(
openDatabaseSpy, sinon.match.any, sinon.match.any, preTestDatabaseItem.databaseUri);
sinon.assert.calledOnceWithExactly(
renameDatabaseItemSpy, postTestDatabaseItem, preTestDatabaseItem.name);
sinon.assert.calledOnceWithExactly(
setCurrentDatabaseItemSpy, postTestDatabaseItem, true);
});
function mockRunTests() {
// runTests is an async generator function. This is not directly supported in sinon
// However, we can pretend the same thing by just returning an async array.
runTestsSpy = sandox.stub();
runTestsSpy.returns(
(async function*() {
(async function* () {
yield Promise.resolve({
test: Uri.parse('file:/ab/c/d.ql').fsPath,
pass: true,