Skip to content
Merged
16 changes: 16 additions & 0 deletions extensions/ql-vscode/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down Expand Up @@ -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": [
Expand Down
12 changes: 12 additions & 0 deletions extensions/ql-vscode/src/extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');

Expand Down
25 changes: 25 additions & 0 deletions extensions/ql-vscode/src/mocks/gh-api-request.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
61 changes: 56 additions & 5 deletions extensions/ql-vscode/src/mocks/mock-gh-api-server.ts
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -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> {
Expand All @@ -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);
Expand Down Expand Up @@ -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.
Expand Down
136 changes: 136 additions & 0 deletions extensions/ql-vscode/src/mocks/request-handlers.ts
Original file line number Diff line number Diff line change
@@ -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),
);
}
}));
}
22 changes: 22 additions & 0 deletions extensions/ql-vscode/src/pure/files.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[]> {
Comment thread
koesie10 marked this conversation as resolved.
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;
}
Loading