Merge pull request #1634 from github/koesie10/record-scenario

Add recording of mock scenarios
This commit is contained in:
Koen Vlaswinkel
2022-10-21 14:52:29 +02:00
committed by GitHub
5 changed files with 368 additions and 16 deletions

View File

@@ -645,6 +645,18 @@
"command": "codeQL.gotoQL",
"title": "CodeQL: Go to QL Code",
"enablement": "codeql.hasQLSource"
},
{
"command": "codeQL.mockGitHubApiServer.startRecording",
"title": "CodeQL: Mock GitHub API Server: Start Scenario Recording"
},
{
"command": "codeQL.mockGitHubApiServer.saveScenario",
"title": "CodeQL: Mock GitHub API Server: Save Scenario"
},
{
"command": "codeQL.mockGitHubApiServer.cancelRecording",
"title": "CodeQL: Mock GitHub API Server: Cancel Scenario Recording"
}
],
"menus": {
@@ -1104,6 +1116,18 @@
{
"command": "codeQLTests.showOutputDifferences",
"when": "false"
},
{
"command": "codeQL.mockGitHubApiServer.startRecording",
"when": "config.codeQL.variantAnalysis.mockGitHubApiServer && !codeQL.mockGitHubApiServer.recording"
},
{
"command": "codeQL.mockGitHubApiServer.saveScenario",
"when": "config.codeQL.variantAnalysis.mockGitHubApiServer && codeQL.mockGitHubApiServer.recording"
},
{
"command": "codeQL.mockGitHubApiServer.cancelRecording",
"when": "config.codeQL.variantAnalysis.mockGitHubApiServer && codeQL.mockGitHubApiServer.recording"
}
],
"editor/context": [

View File

@@ -449,3 +449,9 @@ export class MockGitHubApiConfigListener extends ConfigListener implements MockG
return !!MOCK_GH_API_SERVER.getValue<boolean>();
}
}
const MOCK_GH_API_SERVER_SCENARIOS_PATH = new Setting('mockGitHubApiServerScenariosPath', REMOTE_QUERIES_SETTING);
export function getMockGitHubApiServerScenariosPath(): string | undefined {
return MOCK_GH_API_SERVER_SCENARIOS_PATH.getValue<string>();
}

View File

@@ -116,6 +116,7 @@ import {
} from './remote-queries/gh-api/variant-analysis';
import { VariantAnalysisManager } from './remote-queries/variant-analysis-manager';
import { createVariantAnalysisContentProvider } from './remote-queries/variant-analysis-content-provider';
import { MockGitHubApiServer } from './mocks/mock-gh-api-server';
/**
* extension.ts
@@ -1190,6 +1191,27 @@ async function activateWithInstalledDistribution(
)
);
const mockServer = new MockGitHubApiServer(ctx);
ctx.subscriptions.push(mockServer);
ctx.subscriptions.push(
commandRunner(
'codeQL.mockGitHubApiServer.startRecording',
async () => await mockServer.startRecording(),
)
);
ctx.subscriptions.push(
commandRunner(
'codeQL.mockGitHubApiServer.saveScenario',
async () => await mockServer.saveScenario(),
)
);
ctx.subscriptions.push(
commandRunner(
'codeQL.mockGitHubApiServer.cancelRecording',
async () => await mockServer.cancelRecording(),
)
);
await commands.executeCommand('codeQLDatabases.removeOrphanedDatabases');
void logger.log('Successfully finished extension initialization.');

View File

@@ -1,28 +1,47 @@
import { MockGitHubApiConfigListener } from '../config';
import * as fs from 'fs-extra';
import { commands, env, ExtensionContext, ExtensionMode, 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';
/**
* Enables mocking of the GitHub API server via HTTP interception, using msw.
*/
export class MockGitHubApiServer {
export class MockGitHubApiServer extends DisposableObject {
private isListening: boolean;
private config: MockGitHubApiConfigListener;
constructor() {
private readonly server: SetupServerApi;
private readonly recorder: Recorder;
constructor(
private readonly ctx: ExtensionContext,
) {
super();
this.isListening = false;
this.config = new MockGitHubApiConfigListener();
this.server = setupServer();
this.recorder = this.push(new Recorder(this.server));
this.setupConfigListener();
}
public startServer(): void {
this.isListening = true;
if (this.isListening) {
return;
}
// TODO: Enable HTTP interception.
this.server.listen();
this.isListening = true;
}
public stopServer(): void {
this.server.close();
this.isListening = false;
// TODO: Disable HTTP interception.
}
public loadScenario(): void {
@@ -33,17 +52,122 @@ export class MockGitHubApiServer {
// TODO: Implement logic to list all available scenarios.
}
public recordScenario(): void {
// TODO: Implement logic to record a new scenario to a directory.
public async startRecording(): Promise<void> {
if (this.recorder.isRecording) {
void window.showErrorMessage('A scenario is already being recorded. Use the "Save Scenario" or "Cancel Scenario" commands to finish recording.');
return;
}
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);
await window.showInformationMessage('Recording scenario. To save the scenario, use the "CodeQL Mock GitHub API Server: Save Scenario" command.');
}
public async saveScenario(): Promise<void> {
const scenariosPath = await this.getScenariosPath();
if (!scenariosPath) {
return;
}
// 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', false);
if (!this.recorder.isRecording) {
void window.showErrorMessage('No scenario is currently being recorded.');
return;
}
if (!this.recorder.anyRequestsRecorded) {
void window.showWarningMessage('No requests were recorded. Cancelling scenario.');
await this.stopRecording();
return;
}
const name = await window.showInputBox({
title: 'Save scenario',
prompt: 'Enter a name for the scenario.',
placeHolder: 'successful-run',
});
if (!name) {
return;
}
const filePath = await this.recorder.save(scenariosPath, name);
await this.stopRecording();
const action = await window.showInformationMessage(`Scenario saved to ${filePath}`, 'Open directory');
if (action === 'Open directory') {
await env.openExternal(Uri.file(filePath));
}
}
public async cancelRecording(): Promise<void> {
if (!this.recorder.isRecording) {
void window.showErrorMessage('No scenario is currently being recorded.');
return;
}
await this.stopRecording();
void window.showInformationMessage('Recording cancelled.');
}
private async stopRecording(): Promise<void> {
// 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', false);
await this.recorder.stop();
await this.recorder.clear();
}
private async getScenariosPath(): Promise<string | undefined> {
const scenariosPath = getMockGitHubApiServerScenariosPath();
if (scenariosPath) {
return scenariosPath;
}
if (this.ctx.extensionMode === ExtensionMode.Development) {
const developmentScenariosPath = Uri.joinPath(this.ctx.extensionUri, 'src/mocks/scenarios').fsPath.toString();
if (await fs.pathExists(developmentScenariosPath)) {
return developmentScenariosPath;
}
}
const directories = await window.showOpenDialog({
canSelectFolders: true,
canSelectFiles: false,
canSelectMany: false,
openLabel: 'Select scenarios directory',
title: 'Select scenarios directory',
});
if (directories === undefined || directories.length === 0) {
void window.showErrorMessage('No scenarios directory selected.');
return undefined;
}
// Unfortunately, we cannot save the directory in the configuration because that requires
// the configuration to be registered. If we do that, it would be visible to all users; there
// is no "when" clause that would allow us to only show it to users who have enabled the feature flag.
return directories[0].fsPath;
}
private setupConfigListener(): void {
this.config.onDidChangeConfiguration(() => {
if (this.config.mockServerEnabled && !this.isListening) {
this.startServer();
} else if (!this.config.mockServerEnabled && this.isListening) {
this.stopServer();
}
});
// The config "changes" from the default at startup, so we need to call onConfigChange() to ensure the server is
// started if required.
this.onConfigChange();
this.config.onDidChangeConfiguration(() => this.onConfigChange());
}
private onConfigChange(): void {
if (this.config.mockServerEnabled && !this.isListening) {
this.startServer();
} else if (!this.config.mockServerEnabled && this.isListening) {
this.stopServer();
}
}
}

View File

@@ -0,0 +1,176 @@
import * as fs from 'fs-extra';
import * as path from 'path';
import { MockedRequest } from 'msw';
import { SetupServerApi } from 'msw/node';
import { IsomorphicResponse } from '@mswjs/interceptors';
import { DisposableObject } from '../pure/disposable-object';
import { GitHubApiRequest, RequestKind } from './gh-api-request';
export class Recorder extends DisposableObject {
private readonly allRequests = new Map<string, MockedRequest>();
private currentRecordedScenario: GitHubApiRequest[] = [];
private _isRecording = false;
constructor(
private readonly server: SetupServerApi,
) {
super();
this.onRequestStart = this.onRequestStart.bind(this);
this.onResponseBypass = this.onResponseBypass.bind(this);
}
public get isRecording(): boolean {
return this._isRecording;
}
public get anyRequestsRecorded(): boolean {
return this.currentRecordedScenario.length > 0;
}
public start(): void {
if (this._isRecording) {
return;
}
this._isRecording = true;
this.clear();
this.server.events.on('request:start', this.onRequestStart);
this.server.events.on('response:bypass', this.onResponseBypass);
}
public stop(): void {
if (!this._isRecording) {
return;
}
this._isRecording = false;
this.server.events.removeListener('request:start', this.onRequestStart);
this.server.events.removeListener('response:bypass', this.onResponseBypass);
}
public clear() {
this.currentRecordedScenario = [];
this.allRequests.clear();
}
public async save(scenariosPath: string, name: string): Promise<string> {
const scenarioDirectory = path.join(scenariosPath, name);
await fs.ensureDir(scenarioDirectory);
for (let i = 0; i < this.currentRecordedScenario.length; i++) {
const request = this.currentRecordedScenario[i];
const fileName = `${i}-${request.request.kind}.json`;
const filePath = path.join(scenarioDirectory, fileName);
await fs.writeFile(filePath, JSON.stringify(request, null, 2));
}
this.stop();
return scenarioDirectory;
}
private onRequestStart(request: MockedRequest): void {
this.allRequests.set(request.id, request);
}
private onResponseBypass(response: IsomorphicResponse, requestId: string): void {
const request = this.allRequests.get(requestId);
this.allRequests.delete(requestId);
if (!request) {
return;
}
if (response.body === undefined) {
return;
}
const gitHubApiRequest = createGitHubApiRequest(request.url.toString(), response.status, response.body);
if (!gitHubApiRequest) {
return;
}
this.currentRecordedScenario.push(gitHubApiRequest);
}
}
function createGitHubApiRequest(url: string, status: number, body: string): GitHubApiRequest | undefined {
if (!url) {
return undefined;
}
if (url.match(/\/repos\/[a-zA-Z0-9-_.]+\/[a-zA-Z0-9-_.]+$/)) {
return {
request: {
kind: RequestKind.GetRepo,
},
response: {
status,
body: JSON.parse(body),
},
};
}
if (url.match(/\/repositories\/\d+\/code-scanning\/codeql\/variant-analyses$/)) {
return {
request: {
kind: RequestKind.SubmitVariantAnalysis,
},
response: {
status,
body: JSON.parse(body),
},
};
}
if (url.match(/\/repositories\/\d+\/code-scanning\/codeql\/variant-analyses\/\d+$/)) {
return {
request: {
kind: RequestKind.GetVariantAnalysis,
},
response: {
status,
body: JSON.parse(body),
},
};
}
const repoTaskMatch = url.match(/\/repositories\/\d+\/code-scanning\/codeql\/variant-analyses\/\d+\/repositories\/(?<repositoryId>\d+)$/);
if (repoTaskMatch?.groups?.repositoryId) {
return {
request: {
kind: RequestKind.GetVariantAnalysisRepo,
repositoryId: parseInt(repoTaskMatch.groups.repositoryId, 10),
},
response: {
status,
body: JSON.parse(body),
},
};
}
// if url is a download URL for a variant analysis result, then it's a get-variant-analysis-repoResult.
const repoDownloadMatch = url.match(/objects-origin\.githubusercontent\.com\/codeql-query-console\/codeql-variant-analysis-repo-tasks\/\d+\/(?<repositoryId>\d+)/);
if (repoDownloadMatch?.groups?.repositoryId) {
return {
request: {
kind: RequestKind.GetVariantAnalysisRepoResult,
repositoryId: parseInt(repoDownloadMatch.groups.repositoryId, 10),
},
response: {
status,
body: body as unknown as ArrayBuffer,
}
};
}
return undefined;
}