Merge pull request #1634 from github/koesie10/record-scenario
Add recording of mock scenarios
This commit is contained in:
@@ -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": [
|
||||
|
||||
@@ -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>();
|
||||
}
|
||||
|
||||
@@ -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.');
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
176
extensions/ql-vscode/src/mocks/recorder.ts
Normal file
176
extensions/ql-vscode/src/mocks/recorder.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user