Split mock GitHub API server into VSCode and non-VSCode

This splits the mock GitHub API server class into two parts: one for the
interactive, VSCode parts and one for the non-VSCode parts. This allows
us to use the non-VSCode part in tests.
This commit is contained in:
Koen Vlaswinkel
2022-10-28 14:59:18 +02:00
parent ecdc485e79
commit a9e49f2d72
4 changed files with 274 additions and 170 deletions

View File

@@ -116,7 +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';
import { VSCodeMockGitHubApiServer } from './mocks/vscode-mock-gh-api-server';
import { VariantAnalysisResultsManager } from './remote-queries/variant-analysis-results-manager';
/**
@@ -1194,7 +1194,7 @@ async function activateWithInstalledDistribution(
)
);
const mockServer = new MockGitHubApiServer(ctx);
const mockServer = new VSCodeMockGitHubApiServer(ctx);
ctx.subscriptions.push(mockServer);
ctx.subscriptions.push(
commandRunner(

View File

@@ -1,9 +1,7 @@
import * as path from 'path';
import * as fs from 'fs-extra';
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';
@@ -14,211 +12,128 @@ import { getDirectoryNamesInsidePath } from '../pure/files';
* Enables mocking of the GitHub API server via HTTP interception, using msw.
*/
export class MockGitHubApiServer extends DisposableObject {
private isListening: boolean;
private config: MockGitHubApiConfigListener;
private _isListening: boolean;
private readonly server: SetupServerApi;
private readonly recorder: Recorder;
constructor(
private readonly ctx: ExtensionContext,
) {
constructor() {
super();
this.isListening = false;
this.config = new MockGitHubApiConfigListener();
this._isListening = false;
this.server = setupServer();
this.recorder = this.push(new Recorder(this.server));
this.setupConfigListener();
}
public startServer(): void {
if (this.isListening) {
if (this._isListening) {
return;
}
this.server.listen();
this.isListening = true;
this._isListening = true;
}
public stopServer(): void {
this.server.close();
this.isListening = false;
this._isListening = false;
}
public async loadScenario(): Promise<void> {
const scenariosPath = await this.getScenariosPath();
public async loadScenario(scenarioName: string, scenariosPath?: string): Promise<void> {
if (!scenariosPath) {
return;
scenariosPath = await this.getDefaultScenariosPath();
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);
public async saveScenario(scenarioName: string, scenariosPath?: string): Promise<string> {
if (!scenariosPath) {
scenariosPath = await this.getDefaultScenariosPath();
if (!scenariosPath) {
throw new Error('Could not find scenarios path');
}
}
await window.showInformationMessage(`Loaded scenario '${scenarioName}'`);
const filePath = await this.recorder.save(scenariosPath, scenarioName);
await this.stopRecording();
return filePath;
}
public async unloadScenario(): Promise<void> {
if (!this.isScenarioLoaded()) {
await window.showInformationMessage('No scenario currently loaded');
}
else {
await this.unloadAllScenarios();
await window.showInformationMessage('Unloaded scenario');
if (!this.isScenarioLoaded) {
return;
}
await this.unloadAllScenarios();
}
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;
}
if (this.isScenarioLoaded()) {
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);
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);
public async stopRecording(): Promise<void> {
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;
public async getScenarioNames(scenariosPath?: string): Promise<string[]> {
if (!scenariosPath) {
scenariosPath = await this.getDefaultScenariosPath();
if (!scenariosPath) {
return [];
}
}
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;
return await getDirectoryNamesInsidePath(scenariosPath);
}
private isScenarioLoaded(): boolean {
public get isListening(): boolean {
return this._isListening;
}
public get isRecording(): boolean {
return this.recorder.isRecording;
}
public get anyRequestsRecorded(): boolean {
return this.recorder.anyRequestsRecorded;
}
public get isScenarioLoaded(): boolean {
return this.server.listHandlers().length > 0;
}
public async getDefaultScenariosPath(): Promise<string | undefined> {
// This should be the directory where package.json is located
const rootDirectory = path.resolve(__dirname, '../..');
const scenariosPath = path.resolve(rootDirectory, 'src/mocks/scenarios');
if (await fs.pathExists(scenariosPath)) {
return scenariosPath;
}
return undefined;
}
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.
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,201 @@
import * as fs from 'fs-extra';
import { commands, env, ExtensionContext, ExtensionMode, QuickPickItem, Uri, window } from 'vscode';
import { getMockGitHubApiServerScenariosPath, MockGitHubApiConfigListener } from '../config';
import { DisposableObject } from '../pure/disposable-object';
import { MockGitHubApiServer } from './mock-gh-api-server';
/**
* "Interface" to the mock GitHub API server which implements VSCode interactions, such as
* listening for config changes, asking for scenario names, etc.
*
* This should not be used in tests. For tests, use the `MockGitHubApiServer` class directly.
*/
export class VSCodeMockGitHubApiServer extends DisposableObject {
private readonly server: MockGitHubApiServer;
private readonly config: MockGitHubApiConfigListener;
constructor(
private readonly ctx: ExtensionContext,
) {
super();
this.server = new MockGitHubApiServer();
this.config = new MockGitHubApiConfigListener();
this.setupConfigListener();
}
public async startServer(): Promise<void> {
await this.server.startServer();
}
public async stopServer(): Promise<void> {
await this.server.stopServer();
await commands.executeCommand('setContext', 'codeQL.mockGitHubApiServer.scenarioLoaded', false);
await commands.executeCommand('setContext', 'codeQL.mockGitHubApiServer.recording', false);
}
public async loadScenario(): Promise<void> {
const scenariosPath = await this.getScenariosPath();
if (!scenariosPath) {
return;
}
const scenarioNames = await this.server.getScenarioNames(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;
await this.server.loadScenario(scenarioName, scenariosPath);
// 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 async unloadScenario(): Promise<void> {
if (!this.server.isScenarioLoaded) {
await window.showInformationMessage('No scenario currently loaded');
} else {
await this.server.unloadScenario();
await commands.executeCommand('setContext', 'codeQL.mockGitHubApiServer.scenarioLoaded', false);
await window.showInformationMessage('Unloaded scenario');
}
}
public async startRecording(): Promise<void> {
if (this.server.isRecording) {
void window.showErrorMessage('A scenario is already being recorded. Use the "Save Scenario" or "Cancel Scenario" commands to finish recording.');
return;
}
if (this.server.isScenarioLoaded) {
await this.server.unloadScenario();
await commands.executeCommand('setContext', 'codeQL.mockGitHubApiServer.scenarioLoaded', false);
void window.showInformationMessage('A scenario was loaded so it has been unloaded');
}
await this.server.startRecording();
// 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.server.isRecording) {
void window.showErrorMessage('No scenario is currently being recorded.');
return;
}
if (!this.server.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.server.saveScenario(name, scenariosPath);
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.server.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.server.stopRecording();
}
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 {
// The config "changes" from the default at startup, so we need to call onConfigChange() to ensure the server is
// started if required.
void this.onConfigChange();
this.config.onDidChangeConfiguration(() => void this.onConfigChange());
}
private async onConfigChange(): Promise<void> {
if (this.config.mockServerEnabled && !this.server.isListening) {
await this.startServer();
} else if (!this.config.mockServerEnabled && this.server.isListening) {
await this.stopServer();
}
}
}

View File

@@ -1,11 +1,8 @@
import { expect } from 'chai';
import * as path from 'path';
import * as Octokit from '@octokit/rest';
import { retry } from '@octokit/plugin-retry';
import { setupServer } from 'msw/node';
import {
getRepositoryFromNwo,
getVariantAnalysis,
@@ -16,29 +13,20 @@ import { Credentials } from '../../../../src/authentication';
import {
createMockSubmission
} from '../../../../src/vscode-tests/factories/remote-queries/shared/variant-analysis-submission';
import { createRequestHandlers } from '../../../../src/mocks/request-handlers';
import { MockGitHubApiServer } from '../../../../src/mocks/mock-gh-api-server';
const mockCredentials = {
getOctokit: () => Promise.resolve(new Octokit.Octokit({ retry }))
} as unknown as Credentials;
const server = setupServer();
before(() => server.listen());
afterEach(() => server.resetHandlers());
after(() => server.close());
async function loadScenario(scenarioName: string) {
const handlers = await createRequestHandlers(path.join(__dirname, '../../../../src/mocks/scenarios', scenarioName));
server.use(...handlers);
}
const mockServer = new MockGitHubApiServer();
before(() => mockServer.startServer());
afterEach(() => mockServer.unloadScenario());
after(() => mockServer.stopServer());
describe('submitVariantAnalysis', () => {
it('returns the submitted variant analysis', async () => {
await loadScenario('problem-query-success');
await mockServer.loadScenario('problem-query-success');
const result = await submitVariantAnalysis(mockCredentials, createMockSubmission());
@@ -49,7 +37,7 @@ describe('submitVariantAnalysis', () => {
describe('getVariantAnalysis', () => {
it('returns the variant analysis', async () => {
await loadScenario('problem-query-success');
await mockServer.loadScenario('problem-query-success');
const result = await getVariantAnalysis(mockCredentials, 557804416, 146);
@@ -60,7 +48,7 @@ describe('getVariantAnalysis', () => {
describe('getVariantAnalysisRepo', () => {
it('returns the variant analysis repo task', async () => {
await loadScenario('problem-query-success');
await mockServer.loadScenario('problem-query-success');
const result = await getVariantAnalysisRepo(mockCredentials, 557804416, 146, 206444);
@@ -71,7 +59,7 @@ describe('getVariantAnalysisRepo', () => {
describe('getVariantAnalysisRepoResult', () => {
it('returns the variant analysis repo result', async () => {
await loadScenario('problem-query-success');
await mockServer.loadScenario('problem-query-success');
const result = await getVariantAnalysisRepoResult(mockCredentials, 'https://objects-origin.githubusercontent.com/codeql-query-console/codeql-variant-analysis-repo-tasks/146/206444/f6752c5c-ad60-46ba-b8dc-977546108458');
@@ -83,7 +71,7 @@ describe('getVariantAnalysisRepoResult', () => {
describe('getRepositoryFromNwo', () => {
it('returns the repository', async () => {
await loadScenario('problem-query-success');
await mockServer.loadScenario('problem-query-success');
const result = await getRepositoryFromNwo(mockCredentials, 'github', 'mrva-demo-controller-repo');