Add loading of mock scenarios (#1641)

This commit is contained in:
Charis Kyriakou
2022-10-24 16:27:37 +01:00
committed by GitHub
parent 8a10a49f66
commit 98284d9b2c
7 changed files with 343 additions and 61 deletions

View File

@@ -657,6 +657,14 @@
{
"command": "codeQL.mockGitHubApiServer.cancelRecording",
"title": "CodeQL: Mock GitHub API Server: Cancel Scenario Recording"
},
{
"command": "codeQL.mockGitHubApiServer.loadScenario",
"title": "CodeQL: Mock GitHub API Server: Load Scenario"
},
{
"command": "codeQL.mockGitHubApiServer.unloadScenario",
"title": "CodeQL: Mock GitHub API Server: Unload Scenario"
}
],
"menus": {
@@ -1128,6 +1136,14 @@
{
"command": "codeQL.mockGitHubApiServer.cancelRecording",
"when": "config.codeQL.mockGitHubApiServer.enabled && codeQL.mockGitHubApiServer.recording"
},
{
"command": "codeQL.mockGitHubApiServer.loadScenario",
"when": "config.codeQL.mockGitHubApiServer.enabled && !codeQL.mockGitHubApiServer.recording"
},
{
"command": "codeQL.mockGitHubApiServer.unloadScenario",
"when": "config.codeQL.mockGitHubApiServer.enabled && codeQL.mockGitHubApiServer.scenarioLoaded"
}
],
"editor/context": [

View File

@@ -1211,6 +1211,18 @@ async function activateWithInstalledDistribution(
async () => await mockServer.cancelRecording(),
)
);
ctx.subscriptions.push(
commandRunner(
'codeQL.mockGitHubApiServer.loadScenario',
async () => await mockServer.loadScenario(),
)
);
ctx.subscriptions.push(
commandRunner(
'codeQL.mockGitHubApiServer.unloadScenario',
async () => await mockServer.unloadScenario(),
)
);
await commands.executeCommand('codeQLDatabases.removeOrphanedDatabases');

View File

@@ -74,3 +74,28 @@ export type GitHubApiRequest =
| GetVariantAnalysisRequest
| GetVariantAnalysisRepoRequest
| GetVariantAnalysisRepoResultRequest;
export const isGetRepoRequest = (
request: GitHubApiRequest
): request is GetRepoRequest =>
request.request.kind === RequestKind.GetRepo;
export const isSubmitVariantAnalysisRequest = (
request: GitHubApiRequest
): request is SubmitVariantAnalysisRequest =>
request.request.kind === RequestKind.SubmitVariantAnalysis;
export const isGetVariantAnalysisRequest = (
request: GitHubApiRequest
): request is GetVariantAnalysisRequest =>
request.request.kind === RequestKind.GetVariantAnalysis;
export const isGetVariantAnalysisRepoRequest = (
request: GitHubApiRequest
): request is GetVariantAnalysisRepoRequest =>
request.request.kind === RequestKind.GetVariantAnalysisRepo;
export const isGetVariantAnalysisRepoResultRequest = (
request: GitHubApiRequest
): request is GetVariantAnalysisRepoResultRequest =>
request.request.kind === RequestKind.GetVariantAnalysisRepoResult;

View File

@@ -1,11 +1,14 @@
import * as path from 'path';
import * as fs from 'fs-extra';
import { commands, env, ExtensionContext, ExtensionMode, Uri, window } from 'vscode';
import { commands, env, ExtensionContext, ExtensionMode, QuickPickItem, Uri, window } from 'vscode';
import { setupServer, SetupServerApi } from 'msw/node';
import { getMockGitHubApiServerScenariosPath, MockGitHubApiConfigListener } from '../config';
import { DisposableObject } from '../pure/disposable-object';
import { Recorder } from './recorder';
import { createRequestHandlers } from './request-handlers';
import { getDirectoryNamesInsidePath } from '../pure/files';
/**
* Enables mocking of the GitHub API server via HTTP interception, using msw.
@@ -44,12 +47,46 @@ export class MockGitHubApiServer extends DisposableObject {
this.isListening = false;
}
public loadScenario(): void {
// TODO: Implement logic to load a scenario from a directory.
public async loadScenario(): Promise<void> {
const scenariosPath = await this.getScenariosPath();
if (!scenariosPath) {
return;
}
const scenarioNames = await getDirectoryNamesInsidePath(scenariosPath);
const scenarioQuickPickItems = scenarioNames.map(s => ({ label: s }));
const quickPickOptions = {
placeHolder: 'Select a scenario to load',
};
const selectedScenario = await window.showQuickPick<QuickPickItem>(
scenarioQuickPickItems,
quickPickOptions);
if (!selectedScenario) {
return;
}
const scenarioName = selectedScenario.label;
const scenarioPath = path.join(scenariosPath, scenarioName);
const handlers = await createRequestHandlers(scenarioPath);
this.server.resetHandlers();
this.server.use(...handlers);
// Set a value in the context to track whether we have a scenario loaded.
// This allows us to use this to show/hide commands (see package.json)
await commands.executeCommand('setContext', 'codeQL.mockGitHubApiServer.scenarioLoaded', true);
await window.showInformationMessage(`Loaded scenario '${scenarioName}'`);
}
public listScenarios(): void {
// TODO: Implement logic to list all available scenarios.
public async unloadScenario(): Promise<void> {
if (!this.isScenarioLoaded()) {
await window.showInformationMessage('No scenario currently loaded');
}
else {
await this.unloadAllScenarios();
await window.showInformationMessage('Unloaded scenario');
}
}
public async startRecording(): Promise<void> {
@@ -58,6 +95,11 @@ export class MockGitHubApiServer extends DisposableObject {
return;
}
if (this.isScenarioLoaded()) {
await this.unloadAllScenarios();
void window.showInformationMessage('A scenario was loaded so it has been unloaded');
}
this.recorder.start();
// Set a value in the context to track whether we are recording. This allows us to use this to show/hide commands (see package.json)
await commands.executeCommand('setContext', 'codeQL.mockGitHubApiServer.recording', true);
@@ -156,6 +198,15 @@ export class MockGitHubApiServer extends DisposableObject {
return directories[0].fsPath;
}
private isScenarioLoaded(): boolean {
return this.server.listHandlers().length > 0;
}
private async unloadAllScenarios(): Promise<void> {
this.server.resetHandlers();
await commands.executeCommand('setContext', 'codeQL.mockGitHubApiServer.scenarioLoaded', false);
}
private setupConfigListener(): void {
// The config "changes" from the default at startup, so we need to call onConfigChange() to ensure the server is
// started if required.

View File

@@ -0,0 +1,136 @@
import * as path from 'path';
import * as fs from 'fs-extra';
import { DefaultBodyType, MockedRequest, rest, RestHandler } from 'msw';
import {
GitHubApiRequest,
isGetRepoRequest,
isGetVariantAnalysisRepoRequest,
isGetVariantAnalysisRepoResultRequest,
isGetVariantAnalysisRequest,
isSubmitVariantAnalysisRequest
} from './gh-api-request';
const baseUrl = 'https://api.github.com';
export type RequestHandler = RestHandler<MockedRequest<DefaultBodyType>>;
export async function createRequestHandlers(scenarioDirPath: string): Promise<RequestHandler[]> {
const requests = await readRequestFiles(scenarioDirPath);
const handlers = [
createGetRepoRequestHandler(requests),
createSubmitVariantAnalysisRequestHandler(requests),
createGetVariantAnalysisRequestHandler(requests),
...createGetVariantAnalysisRepoRequestHandlers(requests),
...createGetVariantAnalysisRepoResultRequestHandlers(requests),
];
return handlers;
}
async function readRequestFiles(scenarioDirPath: string): Promise<GitHubApiRequest[]> {
const files = await fs.readdir(scenarioDirPath);
const orderedFiles = files.sort((a, b) => {
const aNum = parseInt(a.split('-')[0]);
const bNum = parseInt(b.split('-')[0]);
return aNum - bNum;
});
const requests: GitHubApiRequest[] = [];
for (const file of orderedFiles) {
const filePath = path.join(scenarioDirPath, file);
const request: GitHubApiRequest = await fs.readJson(filePath, { encoding: 'utf8' });
requests.push(request);
}
return requests;
}
function createGetRepoRequestHandler(requests: GitHubApiRequest[]): RequestHandler {
const getRepoRequests = requests.filter(isGetRepoRequest);
if (getRepoRequests.length > 1) {
throw Error('More than one get repo request found');
}
const getRepoRequest = getRepoRequests[0];
return rest.get(`${baseUrl}/repos/:owner/:name`, (_req, res, ctx) => {
return res(
ctx.status(getRepoRequest.response.status),
ctx.json(getRepoRequest.response.body),
);
});
}
function createSubmitVariantAnalysisRequestHandler(requests: GitHubApiRequest[]): RequestHandler {
const submitVariantAnalysisRequests = requests.filter(isSubmitVariantAnalysisRequest);
if (submitVariantAnalysisRequests.length > 1) {
throw Error('More than one submit variant analysis request found');
}
const getRepoRequest = submitVariantAnalysisRequests[0];
return rest.post(`${baseUrl}/repositories/:controllerRepoId/code-scanning/codeql/variant-analyses`, (_req, res, ctx) => {
return res(
ctx.status(getRepoRequest.response.status),
ctx.json(getRepoRequest.response.body),
);
});
}
function createGetVariantAnalysisRequestHandler(requests: GitHubApiRequest[]): RequestHandler {
const getVariantAnalysisRequests = requests.filter(isGetVariantAnalysisRequest);
let requestIndex = 0;
// During the lifetime of a variant analysis run, there are multiple requests
// to get the variant analysis. We need to return different responses for each
// request, so keep an index of the request and return the appropriate response.
return rest.get(`${baseUrl}/repositories/:controllerRepoId/code-scanning/codeql/variant-analyses/:variantAnalysisId`, (_req, res, ctx) => {
const request = getVariantAnalysisRequests[requestIndex];
if (requestIndex < getVariantAnalysisRequests.length - 1) {
// If there are more requests to come, increment the index.
requestIndex++;
}
return res(
ctx.status(request.response.status),
ctx.json(request.response.body),
);
});
}
function createGetVariantAnalysisRepoRequestHandlers(requests: GitHubApiRequest[]): RequestHandler[] {
const getVariantAnalysisRepoRequests = requests.filter(isGetVariantAnalysisRepoRequest);
return getVariantAnalysisRepoRequests.map(request => rest.get(
`${baseUrl}/repositories/:controllerRepoId/code-scanning/codeql/variant-analyses/:variantAnalysisId/repositories/${request.request.repositoryId}`,
(_req, res, ctx) => {
return res(
ctx.status(request.response.status),
ctx.json(request.response.body),
);
}));
}
function createGetVariantAnalysisRepoResultRequestHandlers(requests: GitHubApiRequest[]): RequestHandler[] {
const getVariantAnalysisRepoResultRequests = requests.filter(isGetVariantAnalysisRepoResultRequest);
return getVariantAnalysisRepoResultRequests.map(request => rest.get(
`https://objects-origin.githubusercontent.com/codeql-query-console/codeql-variant-analysis-repo-tasks/:variantAnalysisId/${request.request.repositoryId}/*`,
(_req, res, ctx) => {
if (request.response.body) {
return res(
ctx.status(request.response.status),
ctx.body(request.response.body),
);
} else {
return res(
ctx.status(request.response.status),
);
}
}));
}

View File

@@ -28,3 +28,25 @@ export async function gatherQlFiles(paths: string[]): Promise<[string[], boolean
}
return [Array.from(gatheredUris), dirFound];
}
/**
* Lists the names of directories inside the given path.
* @param path The path to the directory to read.
* @returns the names of the directories inside the given path.
*/
export async function getDirectoryNamesInsidePath(path: string): Promise<string[]> {
if (!(await fs.pathExists(path))) {
throw Error(`Path does not exist: ${path}`);
}
if (!(await fs.stat(path)).isDirectory()) {
throw Error(`Path is not a directory: ${path}`);
}
const dirItems = await fs.readdir(path, { withFileTypes: true });
const dirNames = dirItems
.filter(dirent => dirent.isDirectory())
.map(dirent => dirent.name);
return dirNames;
}

View File

@@ -3,74 +3,94 @@ import 'chai/register-should';
import * as sinonChai from 'sinon-chai';
import 'mocha';
import * as path from 'path';
import * as chaiAsPromised from 'chai-as-promised';
import { gatherQlFiles } from '../../src/pure/files';
import { gatherQlFiles, getDirectoryNamesInsidePath } from '../../src/pure/files';
chai.use(sinonChai);
chai.use(chaiAsPromised);
const expect = chai.expect;
describe('files', () => {
const dataDir = path.join(path.dirname(__dirname), 'data');
const data2Dir = path.join(path.dirname(__dirname), 'data2');
it('should find one file', async () => {
const singleFile = path.join(dataDir, 'query.ql');
const result = await gatherQlFiles([singleFile]);
expect(result).to.deep.equal([[singleFile], false]);
describe('gatherQlFiles', async () => {
it('should find one file', async () => {
const singleFile = path.join(dataDir, 'query.ql');
const result = await gatherQlFiles([singleFile]);
expect(result).to.deep.equal([[singleFile], false]);
});
it('should find no files', async () => {
const result = await gatherQlFiles([]);
expect(result).to.deep.equal([[], false]);
});
it('should find no files', async () => {
const singleFile = path.join(dataDir, 'library.qll');
const result = await gatherQlFiles([singleFile]);
expect(result).to.deep.equal([[], false]);
});
it('should handle invalid file', async () => {
const singleFile = path.join(dataDir, 'xxx');
const result = await gatherQlFiles([singleFile]);
expect(result).to.deep.equal([[], false]);
});
it('should find two files', async () => {
const singleFile = path.join(dataDir, 'query.ql');
const otherFile = path.join(dataDir, 'multiple-result-sets.ql');
const notFile = path.join(dataDir, 'library.qll');
const invalidFile = path.join(dataDir, 'xxx');
const result = await gatherQlFiles([singleFile, otherFile, notFile, invalidFile]);
expect(result.sort()).to.deep.equal([[singleFile, otherFile], false]);
});
it('should scan a directory', async () => {
const file1 = path.join(dataDir, 'compute-default-strings.ql');
const file2 = path.join(dataDir, 'multiple-result-sets.ql');
const file3 = path.join(dataDir, 'query.ql');
const result = await gatherQlFiles([dataDir]);
expect(result.sort()).to.deep.equal([[file1, file2, file3], true]);
});
it('should scan a directory and some files', async () => {
const singleFile = path.join(dataDir, 'query.ql');
const empty1File = path.join(data2Dir, 'empty1.ql');
const empty2File = path.join(data2Dir, 'sub-folder', 'empty2.ql');
const result = await gatherQlFiles([singleFile, data2Dir]);
expect(result.sort()).to.deep.equal([[singleFile, empty1File, empty2File], true]);
});
it('should avoid duplicates', async () => {
const file1 = path.join(dataDir, 'compute-default-strings.ql');
const file2 = path.join(dataDir, 'multiple-result-sets.ql');
const file3 = path.join(dataDir, 'query.ql');
const result = await gatherQlFiles([file1, dataDir, file3]);
result[0].sort();
expect(result.sort()).to.deep.equal([[file1, file2, file3], true]);
});
});
it('should find no files', async () => {
const result = await gatherQlFiles([]);
expect(result).to.deep.equal([[], false]);
});
describe('getDirectoryNamesInsidePath', async () => {
it('should fail if path does not exist', async () => {
await expect(getDirectoryNamesInsidePath('xxx')).to.eventually.be.rejectedWith('Path does not exist: xxx');
});
it('should find no files', async () => {
const singleFile = path.join(dataDir, 'library.qll');
const result = await gatherQlFiles([singleFile]);
expect(result).to.deep.equal([[], false]);
});
it('should fail if path is not a directory', async () => {
const filePath = path.join(data2Dir, 'empty1.ql');
await expect(getDirectoryNamesInsidePath(filePath)).to.eventually.be.rejectedWith(`Path is not a directory: ${filePath}`);
});
it('should handle invalid file', async () => {
const singleFile = path.join(dataDir, 'xxx');
const result = await gatherQlFiles([singleFile]);
expect(result).to.deep.equal([[], false]);
});
it('should find two files', async () => {
const singleFile = path.join(dataDir, 'query.ql');
const otherFile = path.join(dataDir, 'multiple-result-sets.ql');
const notFile = path.join(dataDir, 'library.qll');
const invalidFile = path.join(dataDir, 'xxx');
const result = await gatherQlFiles([singleFile, otherFile, notFile, invalidFile]);
expect(result.sort()).to.deep.equal([[singleFile, otherFile], false]);
});
it('should scan a directory', async () => {
const file1 = path.join(dataDir, 'compute-default-strings.ql');
const file2 = path.join(dataDir, 'multiple-result-sets.ql');
const file3 = path.join(dataDir, 'query.ql');
const result = await gatherQlFiles([dataDir]);
expect(result.sort()).to.deep.equal([[file1, file2, file3], true]);
});
it('should scan a directory and some files', async () => {
const singleFile = path.join(dataDir, 'query.ql');
const empty1File = path.join(data2Dir, 'empty1.ql');
const empty2File = path.join(data2Dir, 'sub-folder', 'empty2.ql');
const result = await gatherQlFiles([singleFile, data2Dir]);
expect(result.sort()).to.deep.equal([[singleFile, empty1File, empty2File], true]);
});
it('should avoid duplicates', async () => {
const file1 = path.join(dataDir, 'compute-default-strings.ql');
const file2 = path.join(dataDir, 'multiple-result-sets.ql');
const file3 = path.join(dataDir, 'query.ql');
const result = await gatherQlFiles([file1, dataDir, file3]);
result[0].sort();
expect(result.sort()).to.deep.equal([[file1, file2, file3], true]);
it('should find sub-folders', async () => {
const result = await getDirectoryNamesInsidePath(data2Dir);
expect(result).to.deep.equal(['sub-folder']);
});
});
});