Add loading of mock scenarios (#1641)
This commit is contained in:
@@ -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": [
|
||||
|
||||
@@ -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');
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.
|
||||
|
||||
136
extensions/ql-vscode/src/mocks/request-handlers.ts
Normal file
136
extensions/ql-vscode/src/mocks/request-handlers.ts
Normal 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),
|
||||
);
|
||||
}
|
||||
}));
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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']);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user