Skip to content

Commit 4e5abee

Browse files
authored
Merge pull request #1634 from github/koesie10/record-scenario
Add recording of mock scenarios
2 parents 9ba06ef + 71b1b49 commit 4e5abee

File tree

5 files changed

+367
-15
lines changed

5 files changed

+367
-15
lines changed

extensions/ql-vscode/package.json

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -645,6 +645,18 @@
645645
"command": "codeQL.gotoQL",
646646
"title": "CodeQL: Go to QL Code",
647647
"enablement": "codeql.hasQLSource"
648+
},
649+
{
650+
"command": "codeQL.mockGitHubApiServer.startRecording",
651+
"title": "CodeQL: Mock GitHub API Server: Start Scenario Recording"
652+
},
653+
{
654+
"command": "codeQL.mockGitHubApiServer.saveScenario",
655+
"title": "CodeQL: Mock GitHub API Server: Save Scenario"
656+
},
657+
{
658+
"command": "codeQL.mockGitHubApiServer.cancelRecording",
659+
"title": "CodeQL: Mock GitHub API Server: Cancel Scenario Recording"
648660
}
649661
],
650662
"menus": {
@@ -1104,6 +1116,18 @@
11041116
{
11051117
"command": "codeQLTests.showOutputDifferences",
11061118
"when": "false"
1119+
},
1120+
{
1121+
"command": "codeQL.mockGitHubApiServer.startRecording",
1122+
"when": "config.codeQL.variantAnalysis.mockGitHubApiServer && !codeQL.mockGitHubApiServer.recording"
1123+
},
1124+
{
1125+
"command": "codeQL.mockGitHubApiServer.saveScenario",
1126+
"when": "config.codeQL.variantAnalysis.mockGitHubApiServer && codeQL.mockGitHubApiServer.recording"
1127+
},
1128+
{
1129+
"command": "codeQL.mockGitHubApiServer.cancelRecording",
1130+
"when": "config.codeQL.variantAnalysis.mockGitHubApiServer && codeQL.mockGitHubApiServer.recording"
11071131
}
11081132
],
11091133
"editor/context": [

extensions/ql-vscode/src/config.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -449,3 +449,9 @@ export class MockGitHubApiConfigListener extends ConfigListener implements MockG
449449
return !!MOCK_GH_API_SERVER.getValue<boolean>();
450450
}
451451
}
452+
453+
const MOCK_GH_API_SERVER_SCENARIOS_PATH = new Setting('mockGitHubApiServerScenariosPath', REMOTE_QUERIES_SETTING);
454+
455+
export function getMockGitHubApiServerScenariosPath(): string | undefined {
456+
return MOCK_GH_API_SERVER_SCENARIOS_PATH.getValue<string>();
457+
}

extensions/ql-vscode/src/extension.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,7 @@ import {
116116
} from './remote-queries/gh-api/variant-analysis';
117117
import { VariantAnalysisManager } from './remote-queries/variant-analysis-manager';
118118
import { createVariantAnalysisContentProvider } from './remote-queries/variant-analysis-content-provider';
119+
import { MockGitHubApiServer } from './mocks/mock-gh-api-server';
119120

120121
/**
121122
* extension.ts
@@ -1190,6 +1191,27 @@ async function activateWithInstalledDistribution(
11901191
)
11911192
);
11921193

1194+
const mockServer = new MockGitHubApiServer(ctx);
1195+
ctx.subscriptions.push(mockServer);
1196+
ctx.subscriptions.push(
1197+
commandRunner(
1198+
'codeQL.mockGitHubApiServer.startRecording',
1199+
async () => await mockServer.startRecording(),
1200+
)
1201+
);
1202+
ctx.subscriptions.push(
1203+
commandRunner(
1204+
'codeQL.mockGitHubApiServer.saveScenario',
1205+
async () => await mockServer.saveScenario(),
1206+
)
1207+
);
1208+
ctx.subscriptions.push(
1209+
commandRunner(
1210+
'codeQL.mockGitHubApiServer.cancelRecording',
1211+
async () => await mockServer.cancelRecording(),
1212+
)
1213+
);
1214+
11931215
await commands.executeCommand('codeQLDatabases.removeOrphanedDatabases');
11941216

11951217
void logger.log('Successfully finished extension initialization.');
Lines changed: 139 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,28 +1,47 @@
1-
import { MockGitHubApiConfigListener } from '../config';
1+
import * as fs from 'fs-extra';
2+
import { commands, env, ExtensionContext, ExtensionMode, Uri, window } from 'vscode';
3+
import { setupServer, SetupServerApi } from 'msw/node';
4+
5+
import { getMockGitHubApiServerScenariosPath, MockGitHubApiConfigListener } from '../config';
6+
import { DisposableObject } from '../pure/disposable-object';
7+
8+
import { Recorder } from './recorder';
29

310
/**
411
* Enables mocking of the GitHub API server via HTTP interception, using msw.
512
*/
6-
export class MockGitHubApiServer {
13+
export class MockGitHubApiServer extends DisposableObject {
714
private isListening: boolean;
815
private config: MockGitHubApiConfigListener;
916

10-
constructor() {
17+
private readonly server: SetupServerApi;
18+
private readonly recorder: Recorder;
19+
20+
constructor(
21+
private readonly ctx: ExtensionContext,
22+
) {
23+
super();
1124
this.isListening = false;
1225
this.config = new MockGitHubApiConfigListener();
26+
27+
this.server = setupServer();
28+
this.recorder = this.push(new Recorder(this.server));
29+
1330
this.setupConfigListener();
1431
}
1532

1633
public startServer(): void {
17-
this.isListening = true;
34+
if (this.isListening) {
35+
return;
36+
}
1837

19-
// TODO: Enable HTTP interception.
38+
this.server.listen();
39+
this.isListening = true;
2040
}
2141

2242
public stopServer(): void {
43+
this.server.close();
2344
this.isListening = false;
24-
25-
// TODO: Disable HTTP interception.
2645
}
2746

2847
public loadScenario(): void {
@@ -33,17 +52,122 @@ export class MockGitHubApiServer {
3352
// TODO: Implement logic to list all available scenarios.
3453
}
3554

36-
public recordScenario(): void {
37-
// TODO: Implement logic to record a new scenario to a directory.
55+
public async startRecording(): Promise<void> {
56+
if (this.recorder.isRecording) {
57+
void window.showErrorMessage('A scenario is already being recorded. Use the "Save Scenario" or "Cancel Scenario" commands to finish recording.');
58+
return;
59+
}
60+
61+
this.recorder.start();
62+
// 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)
63+
await commands.executeCommand('setContext', 'codeQL.mockGitHubApiServer.recording', true);
64+
65+
await window.showInformationMessage('Recording scenario. To save the scenario, use the "CodeQL Mock GitHub API Server: Save Scenario" command.');
3866
}
3967

40-
private setupConfigListener(): void {
41-
this.config.onDidChangeConfiguration(() => {
42-
if (this.config.mockServerEnabled && !this.isListening) {
43-
this.startServer();
44-
} else if (!this.config.mockServerEnabled && this.isListening) {
45-
this.stopServer();
68+
public async saveScenario(): Promise<void> {
69+
const scenariosPath = await this.getScenariosPath();
70+
if (!scenariosPath) {
71+
return;
72+
}
73+
74+
// 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)
75+
await commands.executeCommand('setContext', 'codeQL.mockGitHubApiServer.recording', false);
76+
77+
if (!this.recorder.isRecording) {
78+
void window.showErrorMessage('No scenario is currently being recorded.');
79+
return;
80+
}
81+
if (!this.recorder.anyRequestsRecorded) {
82+
void window.showWarningMessage('No requests were recorded. Cancelling scenario.');
83+
84+
await this.stopRecording();
85+
86+
return;
87+
}
88+
89+
const name = await window.showInputBox({
90+
title: 'Save scenario',
91+
prompt: 'Enter a name for the scenario.',
92+
placeHolder: 'successful-run',
93+
});
94+
if (!name) {
95+
return;
96+
}
97+
98+
const filePath = await this.recorder.save(scenariosPath, name);
99+
100+
await this.stopRecording();
101+
102+
const action = await window.showInformationMessage(`Scenario saved to ${filePath}`, 'Open directory');
103+
if (action === 'Open directory') {
104+
await env.openExternal(Uri.file(filePath));
105+
}
106+
}
107+
108+
public async cancelRecording(): Promise<void> {
109+
if (!this.recorder.isRecording) {
110+
void window.showErrorMessage('No scenario is currently being recorded.');
111+
return;
112+
}
113+
114+
await this.stopRecording();
115+
116+
void window.showInformationMessage('Recording cancelled.');
117+
}
118+
119+
private async stopRecording(): Promise<void> {
120+
// 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)
121+
await commands.executeCommand('setContext', 'codeQL.mockGitHubApiServer.recording', false);
122+
123+
await this.recorder.stop();
124+
await this.recorder.clear();
125+
}
126+
127+
private async getScenariosPath(): Promise<string | undefined> {
128+
const scenariosPath = getMockGitHubApiServerScenariosPath();
129+
if (scenariosPath) {
130+
return scenariosPath;
131+
}
132+
133+
if (this.ctx.extensionMode === ExtensionMode.Development) {
134+
const developmentScenariosPath = Uri.joinPath(this.ctx.extensionUri, 'src/mocks/scenarios').fsPath.toString();
135+
if (await fs.pathExists(developmentScenariosPath)) {
136+
return developmentScenariosPath;
46137
}
138+
}
139+
140+
const directories = await window.showOpenDialog({
141+
canSelectFolders: true,
142+
canSelectFiles: false,
143+
canSelectMany: false,
144+
openLabel: 'Select scenarios directory',
145+
title: 'Select scenarios directory',
47146
});
147+
if (directories === undefined || directories.length === 0) {
148+
void window.showErrorMessage('No scenarios directory selected.');
149+
return undefined;
150+
}
151+
152+
// Unfortunately, we cannot save the directory in the configuration because that requires
153+
// the configuration to be registered. If we do that, it would be visible to all users; there
154+
// is no "when" clause that would allow us to only show it to users who have enabled the feature flag.
155+
156+
return directories[0].fsPath;
157+
}
158+
159+
private setupConfigListener(): void {
160+
// The config "changes" from the default at startup, so we need to call onConfigChange() to ensure the server is
161+
// started if required.
162+
this.onConfigChange();
163+
this.config.onDidChangeConfiguration(() => this.onConfigChange());
164+
}
165+
166+
private onConfigChange(): void {
167+
if (this.config.mockServerEnabled && !this.isListening) {
168+
this.startServer();
169+
} else if (!this.config.mockServerEnabled && this.isListening) {
170+
this.stopServer();
171+
}
48172
}
49173
}

0 commit comments

Comments
 (0)