Skip to content

Commit 98284d9

Browse files
authored
Add loading of mock scenarios (#1641)
1 parent 8a10a49 commit 98284d9

File tree

7 files changed

+335
-53
lines changed

7 files changed

+335
-53
lines changed

extensions/ql-vscode/package.json

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -657,6 +657,14 @@
657657
{
658658
"command": "codeQL.mockGitHubApiServer.cancelRecording",
659659
"title": "CodeQL: Mock GitHub API Server: Cancel Scenario Recording"
660+
},
661+
{
662+
"command": "codeQL.mockGitHubApiServer.loadScenario",
663+
"title": "CodeQL: Mock GitHub API Server: Load Scenario"
664+
},
665+
{
666+
"command": "codeQL.mockGitHubApiServer.unloadScenario",
667+
"title": "CodeQL: Mock GitHub API Server: Unload Scenario"
660668
}
661669
],
662670
"menus": {
@@ -1128,6 +1136,14 @@
11281136
{
11291137
"command": "codeQL.mockGitHubApiServer.cancelRecording",
11301138
"when": "config.codeQL.mockGitHubApiServer.enabled && codeQL.mockGitHubApiServer.recording"
1139+
},
1140+
{
1141+
"command": "codeQL.mockGitHubApiServer.loadScenario",
1142+
"when": "config.codeQL.mockGitHubApiServer.enabled && !codeQL.mockGitHubApiServer.recording"
1143+
},
1144+
{
1145+
"command": "codeQL.mockGitHubApiServer.unloadScenario",
1146+
"when": "config.codeQL.mockGitHubApiServer.enabled && codeQL.mockGitHubApiServer.scenarioLoaded"
11311147
}
11321148
],
11331149
"editor/context": [

extensions/ql-vscode/src/extension.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1211,6 +1211,18 @@ async function activateWithInstalledDistribution(
12111211
async () => await mockServer.cancelRecording(),
12121212
)
12131213
);
1214+
ctx.subscriptions.push(
1215+
commandRunner(
1216+
'codeQL.mockGitHubApiServer.loadScenario',
1217+
async () => await mockServer.loadScenario(),
1218+
)
1219+
);
1220+
ctx.subscriptions.push(
1221+
commandRunner(
1222+
'codeQL.mockGitHubApiServer.unloadScenario',
1223+
async () => await mockServer.unloadScenario(),
1224+
)
1225+
);
12141226

12151227
await commands.executeCommand('codeQLDatabases.removeOrphanedDatabases');
12161228

extensions/ql-vscode/src/mocks/gh-api-request.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,3 +74,28 @@ export type GitHubApiRequest =
7474
| GetVariantAnalysisRequest
7575
| GetVariantAnalysisRepoRequest
7676
| GetVariantAnalysisRepoResultRequest;
77+
78+
export const isGetRepoRequest = (
79+
request: GitHubApiRequest
80+
): request is GetRepoRequest =>
81+
request.request.kind === RequestKind.GetRepo;
82+
83+
export const isSubmitVariantAnalysisRequest = (
84+
request: GitHubApiRequest
85+
): request is SubmitVariantAnalysisRequest =>
86+
request.request.kind === RequestKind.SubmitVariantAnalysis;
87+
88+
export const isGetVariantAnalysisRequest = (
89+
request: GitHubApiRequest
90+
): request is GetVariantAnalysisRequest =>
91+
request.request.kind === RequestKind.GetVariantAnalysis;
92+
93+
export const isGetVariantAnalysisRepoRequest = (
94+
request: GitHubApiRequest
95+
): request is GetVariantAnalysisRepoRequest =>
96+
request.request.kind === RequestKind.GetVariantAnalysisRepo;
97+
98+
export const isGetVariantAnalysisRepoResultRequest = (
99+
request: GitHubApiRequest
100+
): request is GetVariantAnalysisRepoResultRequest =>
101+
request.request.kind === RequestKind.GetVariantAnalysisRepoResult;

extensions/ql-vscode/src/mocks/mock-gh-api-server.ts

Lines changed: 56 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,14 @@
1+
import * as path from 'path';
12
import * as fs from 'fs-extra';
2-
import { commands, env, ExtensionContext, ExtensionMode, Uri, window } from 'vscode';
3+
import { commands, env, ExtensionContext, ExtensionMode, QuickPickItem, Uri, window } from 'vscode';
34
import { setupServer, SetupServerApi } from 'msw/node';
45

56
import { getMockGitHubApiServerScenariosPath, MockGitHubApiConfigListener } from '../config';
67
import { DisposableObject } from '../pure/disposable-object';
78

89
import { Recorder } from './recorder';
10+
import { createRequestHandlers } from './request-handlers';
11+
import { getDirectoryNamesInsidePath } from '../pure/files';
912

1013
/**
1114
* Enables mocking of the GitHub API server via HTTP interception, using msw.
@@ -44,12 +47,46 @@ export class MockGitHubApiServer extends DisposableObject {
4447
this.isListening = false;
4548
}
4649

47-
public loadScenario(): void {
48-
// TODO: Implement logic to load a scenario from a directory.
50+
public async loadScenario(): Promise<void> {
51+
const scenariosPath = await this.getScenariosPath();
52+
if (!scenariosPath) {
53+
return;
54+
}
55+
56+
const scenarioNames = await getDirectoryNamesInsidePath(scenariosPath);
57+
const scenarioQuickPickItems = scenarioNames.map(s => ({ label: s }));
58+
const quickPickOptions = {
59+
placeHolder: 'Select a scenario to load',
60+
};
61+
const selectedScenario = await window.showQuickPick<QuickPickItem>(
62+
scenarioQuickPickItems,
63+
quickPickOptions);
64+
if (!selectedScenario) {
65+
return;
66+
}
67+
68+
const scenarioName = selectedScenario.label;
69+
const scenarioPath = path.join(scenariosPath, scenarioName);
70+
71+
const handlers = await createRequestHandlers(scenarioPath);
72+
this.server.resetHandlers();
73+
this.server.use(...handlers);
74+
75+
// Set a value in the context to track whether we have a scenario loaded.
76+
// This allows us to use this to show/hide commands (see package.json)
77+
await commands.executeCommand('setContext', 'codeQL.mockGitHubApiServer.scenarioLoaded', true);
78+
79+
await window.showInformationMessage(`Loaded scenario '${scenarioName}'`);
4980
}
5081

51-
public listScenarios(): void {
52-
// TODO: Implement logic to list all available scenarios.
82+
public async unloadScenario(): Promise<void> {
83+
if (!this.isScenarioLoaded()) {
84+
await window.showInformationMessage('No scenario currently loaded');
85+
}
86+
else {
87+
await this.unloadAllScenarios();
88+
await window.showInformationMessage('Unloaded scenario');
89+
}
5390
}
5491

5592
public async startRecording(): Promise<void> {
@@ -58,6 +95,11 @@ export class MockGitHubApiServer extends DisposableObject {
5895
return;
5996
}
6097

98+
if (this.isScenarioLoaded()) {
99+
await this.unloadAllScenarios();
100+
void window.showInformationMessage('A scenario was loaded so it has been unloaded');
101+
}
102+
61103
this.recorder.start();
62104
// 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)
63105
await commands.executeCommand('setContext', 'codeQL.mockGitHubApiServer.recording', true);
@@ -156,6 +198,15 @@ export class MockGitHubApiServer extends DisposableObject {
156198
return directories[0].fsPath;
157199
}
158200

201+
private isScenarioLoaded(): boolean {
202+
return this.server.listHandlers().length > 0;
203+
}
204+
205+
private async unloadAllScenarios(): Promise<void> {
206+
this.server.resetHandlers();
207+
await commands.executeCommand('setContext', 'codeQL.mockGitHubApiServer.scenarioLoaded', false);
208+
}
209+
159210
private setupConfigListener(): void {
160211
// The config "changes" from the default at startup, so we need to call onConfigChange() to ensure the server is
161212
// started if required.
Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
import * as path from 'path';
2+
import * as fs from 'fs-extra';
3+
import { DefaultBodyType, MockedRequest, rest, RestHandler } from 'msw';
4+
import {
5+
GitHubApiRequest,
6+
isGetRepoRequest,
7+
isGetVariantAnalysisRepoRequest,
8+
isGetVariantAnalysisRepoResultRequest,
9+
isGetVariantAnalysisRequest,
10+
isSubmitVariantAnalysisRequest
11+
} from './gh-api-request';
12+
13+
const baseUrl = 'https://api.github.com';
14+
15+
export type RequestHandler = RestHandler<MockedRequest<DefaultBodyType>>;
16+
17+
export async function createRequestHandlers(scenarioDirPath: string): Promise<RequestHandler[]> {
18+
const requests = await readRequestFiles(scenarioDirPath);
19+
20+
const handlers = [
21+
createGetRepoRequestHandler(requests),
22+
createSubmitVariantAnalysisRequestHandler(requests),
23+
createGetVariantAnalysisRequestHandler(requests),
24+
...createGetVariantAnalysisRepoRequestHandlers(requests),
25+
...createGetVariantAnalysisRepoResultRequestHandlers(requests),
26+
];
27+
28+
return handlers;
29+
}
30+
31+
async function readRequestFiles(scenarioDirPath: string): Promise<GitHubApiRequest[]> {
32+
const files = await fs.readdir(scenarioDirPath);
33+
34+
const orderedFiles = files.sort((a, b) => {
35+
const aNum = parseInt(a.split('-')[0]);
36+
const bNum = parseInt(b.split('-')[0]);
37+
return aNum - bNum;
38+
});
39+
40+
const requests: GitHubApiRequest[] = [];
41+
for (const file of orderedFiles) {
42+
const filePath = path.join(scenarioDirPath, file);
43+
const request: GitHubApiRequest = await fs.readJson(filePath, { encoding: 'utf8' });
44+
requests.push(request);
45+
}
46+
47+
return requests;
48+
}
49+
50+
function createGetRepoRequestHandler(requests: GitHubApiRequest[]): RequestHandler {
51+
const getRepoRequests = requests.filter(isGetRepoRequest);
52+
53+
if (getRepoRequests.length > 1) {
54+
throw Error('More than one get repo request found');
55+
}
56+
57+
const getRepoRequest = getRepoRequests[0];
58+
59+
return rest.get(`${baseUrl}/repos/:owner/:name`, (_req, res, ctx) => {
60+
return res(
61+
ctx.status(getRepoRequest.response.status),
62+
ctx.json(getRepoRequest.response.body),
63+
);
64+
});
65+
}
66+
67+
function createSubmitVariantAnalysisRequestHandler(requests: GitHubApiRequest[]): RequestHandler {
68+
const submitVariantAnalysisRequests = requests.filter(isSubmitVariantAnalysisRequest);
69+
70+
if (submitVariantAnalysisRequests.length > 1) {
71+
throw Error('More than one submit variant analysis request found');
72+
}
73+
74+
const getRepoRequest = submitVariantAnalysisRequests[0];
75+
76+
return rest.post(`${baseUrl}/repositories/:controllerRepoId/code-scanning/codeql/variant-analyses`, (_req, res, ctx) => {
77+
return res(
78+
ctx.status(getRepoRequest.response.status),
79+
ctx.json(getRepoRequest.response.body),
80+
);
81+
});
82+
}
83+
84+
function createGetVariantAnalysisRequestHandler(requests: GitHubApiRequest[]): RequestHandler {
85+
const getVariantAnalysisRequests = requests.filter(isGetVariantAnalysisRequest);
86+
let requestIndex = 0;
87+
88+
// During the lifetime of a variant analysis run, there are multiple requests
89+
// to get the variant analysis. We need to return different responses for each
90+
// request, so keep an index of the request and return the appropriate response.
91+
return rest.get(`${baseUrl}/repositories/:controllerRepoId/code-scanning/codeql/variant-analyses/:variantAnalysisId`, (_req, res, ctx) => {
92+
const request = getVariantAnalysisRequests[requestIndex];
93+
94+
if (requestIndex < getVariantAnalysisRequests.length - 1) {
95+
// If there are more requests to come, increment the index.
96+
requestIndex++;
97+
}
98+
99+
return res(
100+
ctx.status(request.response.status),
101+
ctx.json(request.response.body),
102+
);
103+
});
104+
}
105+
106+
function createGetVariantAnalysisRepoRequestHandlers(requests: GitHubApiRequest[]): RequestHandler[] {
107+
const getVariantAnalysisRepoRequests = requests.filter(isGetVariantAnalysisRepoRequest);
108+
109+
return getVariantAnalysisRepoRequests.map(request => rest.get(
110+
`${baseUrl}/repositories/:controllerRepoId/code-scanning/codeql/variant-analyses/:variantAnalysisId/repositories/${request.request.repositoryId}`,
111+
(_req, res, ctx) => {
112+
return res(
113+
ctx.status(request.response.status),
114+
ctx.json(request.response.body),
115+
);
116+
}));
117+
}
118+
119+
function createGetVariantAnalysisRepoResultRequestHandlers(requests: GitHubApiRequest[]): RequestHandler[] {
120+
const getVariantAnalysisRepoResultRequests = requests.filter(isGetVariantAnalysisRepoResultRequest);
121+
122+
return getVariantAnalysisRepoResultRequests.map(request => rest.get(
123+
`https://objects-origin.githubusercontent.com/codeql-query-console/codeql-variant-analysis-repo-tasks/:variantAnalysisId/${request.request.repositoryId}/*`,
124+
(_req, res, ctx) => {
125+
if (request.response.body) {
126+
return res(
127+
ctx.status(request.response.status),
128+
ctx.body(request.response.body),
129+
);
130+
} else {
131+
return res(
132+
ctx.status(request.response.status),
133+
);
134+
}
135+
}));
136+
}

extensions/ql-vscode/src/pure/files.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,3 +28,25 @@ export async function gatherQlFiles(paths: string[]): Promise<[string[], boolean
2828
}
2929
return [Array.from(gatheredUris), dirFound];
3030
}
31+
32+
/**
33+
* Lists the names of directories inside the given path.
34+
* @param path The path to the directory to read.
35+
* @returns the names of the directories inside the given path.
36+
*/
37+
export async function getDirectoryNamesInsidePath(path: string): Promise<string[]> {
38+
if (!(await fs.pathExists(path))) {
39+
throw Error(`Path does not exist: ${path}`);
40+
}
41+
if (!(await fs.stat(path)).isDirectory()) {
42+
throw Error(`Path is not a directory: ${path}`);
43+
}
44+
45+
const dirItems = await fs.readdir(path, { withFileTypes: true });
46+
47+
const dirNames = dirItems
48+
.filter(dirent => dirent.isDirectory())
49+
.map(dirent => dirent.name);
50+
51+
return dirNames;
52+
}

0 commit comments

Comments
 (0)