Merge remote-tracking branch 'origin/main' into koesie10/unique-database-names

This commit is contained in:
Koen Vlaswinkel
2024-03-11 12:49:32 +01:00
17 changed files with 664 additions and 397 deletions

View File

@@ -176,3 +176,32 @@ export function findCommonParentDir(...paths: string[]): string {
function isTopLevelPath(path: string): boolean { function isTopLevelPath(path: string): boolean {
return dirname(path) === path; return dirname(path) === path;
} }
/**
* Recursively looks for a file in a directory. If the file exists, then returns the directory containing the file.
*
* @param dir The directory to search
* @param toFind The file to recursively look for in this directory
*
* @returns the directory containing the file, or undefined if not found.
*/
export async function findDirWithFile(
dir: string,
...toFind: string[]
): Promise<string | undefined> {
if (!(await stat(dir)).isDirectory()) {
return;
}
const files = await readdir(dir);
if (toFind.some((file) => files.includes(file))) {
return dir;
}
for (const file of files) {
const newPath = join(dir, file);
const result = await findDirWithFile(newPath, ...toFind);
if (result) {
return result;
}
}
return;
}

View File

@@ -1,9 +1,4 @@
import type { import type { WebviewView, WebviewViewProvider } from "vscode";
CancellationToken,
WebviewView,
WebviewViewProvider,
WebviewViewResolveContext,
} from "vscode";
import { Uri } from "vscode"; import { Uri } from "vscode";
import type { WebviewKind, WebviewMessage } from "./webview-html"; import type { WebviewKind, WebviewMessage } from "./webview-html";
import { getHtmlForWebview } from "./webview-html"; import { getHtmlForWebview } from "./webview-html";
@@ -28,11 +23,7 @@ export abstract class AbstractWebviewViewProvider<
* This is called when a view first becomes visible. This may happen when the view is * This is called when a view first becomes visible. This may happen when the view is
* first loaded or when the user hides and then shows a view again. * first loaded or when the user hides and then shows a view again.
*/ */
public resolveWebviewView( public resolveWebviewView(webviewView: WebviewView) {
webviewView: WebviewView,
_context: WebviewViewResolveContext,
_token: CancellationToken,
) {
webviewView.webview.options = { webviewView.webview.options = {
enableScripts: true, enableScripts: true,
localResourceRoots: [Uri.file(this.app.extensionPath)], localResourceRoots: [Uri.file(this.app.extensionPath)],

View File

@@ -10,7 +10,6 @@ import {
pathExists, pathExists,
createWriteStream, createWriteStream,
remove, remove,
stat,
readdir, readdir,
} from "fs-extra"; } from "fs-extra";
import { basename, join } from "path"; import { basename, join } from "path";
@@ -36,11 +35,12 @@ import {
} from "../config"; } from "../config";
import { showAndLogInformationMessage } from "../common/logging"; import { showAndLogInformationMessage } from "../common/logging";
import { AppOctokit } from "../common/octokit"; import { AppOctokit } from "../common/octokit";
import { getLanguageDisplayName } from "../common/query-language";
import type { DatabaseOrigin } from "./local-databases/database-origin"; import type { DatabaseOrigin } from "./local-databases/database-origin";
import { createTimeoutSignal } from "../common/fetch-stream"; import { createTimeoutSignal } from "../common/fetch-stream";
import type { App } from "../common/app"; import type { App } from "../common/app";
import { createFilenameFromString } from "../common/filenames"; import { createFilenameFromString } from "../common/filenames";
import { findDirWithFile } from "../common/files";
import { convertGithubNwoToDatabaseUrl } from "./github-databases/api";
/** /**
* Prompts a user to fetch a database from a remote location. Database is assumed to be an archive file. * Prompts a user to fetch a database from a remote location. Database is assumed to be an archive file.
@@ -406,6 +406,7 @@ async function databaseArchiveFetcher(
nameOverride, nameOverride,
{ {
addSourceArchiveFolder, addSourceArchiveFolder,
extensionManagedLocation: unzipPath,
}, },
); );
return item; return item;
@@ -617,125 +618,6 @@ function isFile(databaseUrl: string) {
return Uri.parse(databaseUrl).scheme === "file"; return Uri.parse(databaseUrl).scheme === "file";
} }
/**
* Recursively looks for a file in a directory. If the file exists, then returns the directory containing the file.
*
* @param dir The directory to search
* @param toFind The file to recursively look for in this directory
*
* @returns the directory containing the file, or undefined if not found.
*/
// exported for testing
export async function findDirWithFile(
dir: string,
...toFind: string[]
): Promise<string | undefined> {
if (!(await stat(dir)).isDirectory()) {
return;
}
const files = await readdir(dir);
if (toFind.some((file) => files.includes(file))) {
return dir;
}
for (const file of files) {
const newPath = join(dir, file);
const result = await findDirWithFile(newPath, ...toFind);
if (result) {
return result;
}
}
return;
}
export async function convertGithubNwoToDatabaseUrl(
nwo: string,
octokit: Octokit,
progress: ProgressCallback,
language?: string,
): Promise<
| {
databaseUrl: string;
owner: string;
name: string;
databaseId: number;
databaseCreatedAt: string;
commitOid: string | null;
}
| undefined
> {
try {
const [owner, repo] = nwo.split("/");
const response = await octokit.rest.codeScanning.listCodeqlDatabases({
owner,
repo,
});
const languages = response.data.map((db) => db.language);
if (!language || !languages.includes(language)) {
language = await promptForLanguage(languages, progress);
if (!language) {
return;
}
}
const databaseForLanguage = response.data.find(
(db) => db.language === language,
);
if (!databaseForLanguage) {
throw new Error(`No database found for language '${language}'`);
}
return {
databaseUrl: databaseForLanguage.url,
owner,
name: repo,
databaseId: databaseForLanguage.id,
databaseCreatedAt: databaseForLanguage.created_at,
commitOid: databaseForLanguage.commit_oid ?? null,
};
} catch (e) {
void extLogger.log(`Error: ${getErrorMessage(e)}`);
throw new Error(`Unable to get database for '${nwo}'`);
}
}
export async function promptForLanguage(
languages: string[],
progress: ProgressCallback | undefined,
): Promise<string | undefined> {
progress?.({
message: "Choose language",
step: 2,
maxStep: 2,
});
if (!languages.length) {
throw new Error("No databases found");
}
if (languages.length === 1) {
return languages[0];
}
const items = languages
.map((language) => ({
label: getLanguageDisplayName(language),
description: language,
language,
}))
.sort((a, b) => a.label.localeCompare(b.label));
const selectedItem = await window.showQuickPick(items, {
placeHolder: "Select the database language to download:",
ignoreFocusOut: true,
});
if (!selectedItem) {
return undefined;
}
return selectedItem.language;
}
/** /**
* Databases created by the old odasa tool will not have a zipped * Databases created by the old odasa tool will not have a zipped
* source location. However, this extension works better if sources * source location. However, this extension works better if sources

View File

@@ -5,6 +5,11 @@ import { showNeverAskAgainDialog } from "../../common/vscode/dialog";
import type { GitHubDatabaseConfig } from "../../config"; import type { GitHubDatabaseConfig } from "../../config";
import type { Credentials } from "../../common/authentication"; import type { Credentials } from "../../common/authentication";
import { AppOctokit } from "../../common/octokit"; import { AppOctokit } from "../../common/octokit";
import type { ProgressCallback } from "../../common/vscode/progress";
import { getErrorMessage } from "../../common/helpers-pure";
import { getLanguageDisplayName } from "../../common/query-language";
import { window } from "vscode";
import { extLogger } from "../../common/logging/vscode";
export type CodeqlDatabase = export type CodeqlDatabase =
RestEndpointMethodTypes["codeScanning"]["listCodeqlDatabases"]["response"]["data"][number]; RestEndpointMethodTypes["codeScanning"]["listCodeqlDatabases"]["response"]["data"][number];
@@ -108,3 +113,92 @@ export async function listDatabases(
octokit, octokit,
}; };
} }
export async function convertGithubNwoToDatabaseUrl(
nwo: string,
octokit: Octokit,
progress: ProgressCallback,
language?: string,
): Promise<
| {
databaseUrl: string;
owner: string;
name: string;
databaseId: number;
databaseCreatedAt: string;
commitOid: string | null;
}
| undefined
> {
try {
const [owner, repo] = nwo.split("/");
const response = await octokit.rest.codeScanning.listCodeqlDatabases({
owner,
repo,
});
const languages = response.data.map((db) => db.language);
if (!language || !languages.includes(language)) {
language = await promptForLanguage(languages, progress);
if (!language) {
return;
}
}
const databaseForLanguage = response.data.find(
(db) => db.language === language,
);
if (!databaseForLanguage) {
throw new Error(`No database found for language '${language}'`);
}
return {
databaseUrl: databaseForLanguage.url,
owner,
name: repo,
databaseId: databaseForLanguage.id,
databaseCreatedAt: databaseForLanguage.created_at,
commitOid: databaseForLanguage.commit_oid ?? null,
};
} catch (e) {
void extLogger.log(`Error: ${getErrorMessage(e)}`);
throw new Error(`Unable to get database for '${nwo}'`);
}
}
async function promptForLanguage(
languages: string[],
progress: ProgressCallback | undefined,
): Promise<string | undefined> {
progress?.({
message: "Choose language",
step: 2,
maxStep: 2,
});
if (!languages.length) {
throw new Error("No databases found");
}
if (languages.length === 1) {
return languages[0];
}
const items = languages
.map((language) => ({
label: getLanguageDisplayName(language),
description: language,
language,
}))
.sort((a, b) => a.label.localeCompare(b.label));
const selectedItem = await window.showQuickPick(items, {
placeHolder: "Select the database language to download:",
ignoreFocusOut: true,
});
if (!selectedItem) {
return undefined;
}
return selectedItem.language;
}

View File

@@ -66,6 +66,10 @@ export class DatabaseItemImpl implements DatabaseItem {
return this.options.origin; return this.options.origin;
} }
public get extensionManagedLocation(): string | undefined {
return this.options.extensionManagedLocation;
}
public resolveSourceFile(uriStr: string | undefined): Uri { public resolveSourceFile(uriStr: string | undefined): Uri {
const sourceArchive = this.sourceArchive; const sourceArchive = this.sourceArchive;
const uri = uriStr ? Uri.parse(uriStr, true) : undefined; const uri = uriStr ? Uri.parse(uriStr, true) : undefined;

View File

@@ -31,6 +31,12 @@ export interface DatabaseItem {
*/ */
readonly origin: DatabaseOrigin | undefined; readonly origin: DatabaseOrigin | undefined;
/**
* The location of the base storage location as managed by the extension, or undefined
* if unknown or not managed by the extension.
*/
readonly extensionManagedLocation: string | undefined;
/** If the database is invalid, describes why. */ /** If the database is invalid, describes why. */
readonly error: Error | undefined; readonly error: Error | undefined;

View File

@@ -82,6 +82,10 @@ function eventFired<T>(
} }
type OpenDatabaseOptions = { type OpenDatabaseOptions = {
/**
* A location that is managed by the extension.
*/
extensionManagedLocation?: string;
isTutorialDatabase?: boolean; isTutorialDatabase?: boolean;
/** /**
* Whether to add a workspace folder containing the source archive to the workspace. Default is true. * Whether to add a workspace folder containing the source archive to the workspace. Default is true.
@@ -141,6 +145,7 @@ export class DatabaseManager extends DisposableObject {
makeSelected = true, makeSelected = true,
displayName?: string, displayName?: string,
{ {
extensionManagedLocation,
isTutorialDatabase = false, isTutorialDatabase = false,
addSourceArchiveFolder = addDatabaseSourceToWorkspace(), addSourceArchiveFolder = addDatabaseSourceToWorkspace(),
}: OpenDatabaseOptions = {}, }: OpenDatabaseOptions = {},
@@ -149,6 +154,7 @@ export class DatabaseManager extends DisposableObject {
uri, uri,
origin, origin,
displayName, displayName,
extensionManagedLocation,
); );
return await this.addExistingDatabaseItem( return await this.addExistingDatabaseItem(
@@ -202,6 +208,7 @@ export class DatabaseManager extends DisposableObject {
uri: vscode.Uri, uri: vscode.Uri,
origin: DatabaseOrigin | undefined, origin: DatabaseOrigin | undefined,
displayName: string | undefined, displayName: string | undefined,
extensionManagedLocation?: string,
): Promise<DatabaseItemImpl> { ): Promise<DatabaseItemImpl> {
const contents = await DatabaseResolver.resolveDatabaseContents(uri); const contents = await DatabaseResolver.resolveDatabaseContents(uri);
const fullOptions: FullDatabaseOptions = { const fullOptions: FullDatabaseOptions = {
@@ -210,6 +217,7 @@ export class DatabaseManager extends DisposableObject {
dateAdded: Date.now(), dateAdded: Date.now(),
language: await this.getPrimaryLanguage(uri.fsPath), language: await this.getPrimaryLanguage(uri.fsPath),
origin, origin,
extensionManagedLocation,
}; };
const databaseItem = new DatabaseItemImpl(uri, contents, fullOptions); const databaseItem = new DatabaseItemImpl(uri, contents, fullOptions);
@@ -370,6 +378,7 @@ export class DatabaseManager extends DisposableObject {
let dateAdded = undefined; let dateAdded = undefined;
let language = undefined; let language = undefined;
let origin = undefined; let origin = undefined;
let extensionManagedLocation = undefined;
if (state.options) { if (state.options) {
if (typeof state.options.displayName === "string") { if (typeof state.options.displayName === "string") {
displayName = state.options.displayName; displayName = state.options.displayName;
@@ -379,6 +388,7 @@ export class DatabaseManager extends DisposableObject {
} }
language = state.options.language; language = state.options.language;
origin = state.options.origin; origin = state.options.origin;
extensionManagedLocation = state.options.extensionManagedLocation;
} }
const dbBaseUri = vscode.Uri.parse(state.uri, true); const dbBaseUri = vscode.Uri.parse(state.uri, true);
@@ -392,6 +402,7 @@ export class DatabaseManager extends DisposableObject {
dateAdded, dateAdded,
language, language,
origin, origin,
extensionManagedLocation,
}; };
const item = new DatabaseItemImpl(dbBaseUri, undefined, fullOptions); const item = new DatabaseItemImpl(dbBaseUri, undefined, fullOptions);
@@ -583,15 +594,20 @@ export class DatabaseManager extends DisposableObject {
// Remove this database item from the allow-list // Remove this database item from the allow-list
await this.deregisterDatabase(item); await this.deregisterDatabase(item);
// Find whether we know directly which directory we should remove
const directoryToRemove = item.extensionManagedLocation
? vscode.Uri.file(item.extensionManagedLocation)
: item.databaseUri;
// Delete folder from file system only if it is controlled by the extension // Delete folder from file system only if it is controlled by the extension
if (this.isExtensionControlledLocation(item.databaseUri)) { if (this.isExtensionControlledLocation(directoryToRemove)) {
void extLogger.log("Deleting database from filesystem."); void extLogger.log("Deleting database from filesystem.");
await remove(item.databaseUri.fsPath).then( await remove(directoryToRemove.fsPath).then(
() => void extLogger.log(`Deleted '${item.databaseUri.fsPath}'`), () => void extLogger.log(`Deleted '${directoryToRemove.fsPath}'`),
(e: unknown) => (e: unknown) =>
void extLogger.log( void extLogger.log(
`Failed to delete '${ `Failed to delete '${
item.databaseUri.fsPath directoryToRemove.fsPath
}'. Reason: ${getErrorMessage(e)}`, }'. Reason: ${getErrorMessage(e)}`,
), ),
); );

View File

@@ -5,10 +5,12 @@ export interface DatabaseOptions {
dateAdded?: number | undefined; dateAdded?: number | undefined;
language?: string; language?: string;
origin?: DatabaseOrigin; origin?: DatabaseOrigin;
extensionManagedLocation?: string;
} }
export interface FullDatabaseOptions extends DatabaseOptions { export interface FullDatabaseOptions extends DatabaseOptions {
dateAdded: number | undefined; dateAdded: number | undefined;
language: string | undefined; language: string | undefined;
origin: DatabaseOrigin | undefined; origin: DatabaseOrigin | undefined;
extensionManagedLocation: string | undefined;
} }

View File

@@ -22,7 +22,7 @@ interface InternalDbModelingState {
modelEvaluationRun: ModelEvaluationRun | undefined; modelEvaluationRun: ModelEvaluationRun | undefined;
} }
interface DbModelingState { export interface DbModelingState {
readonly databaseItem: DatabaseItem; readonly databaseItem: DatabaseItem;
readonly methods: readonly Method[]; readonly methods: readonly Method[];
readonly hideModeledMethods: boolean; readonly hideModeledMethods: boolean;
@@ -36,7 +36,7 @@ interface DbModelingState {
readonly modelEvaluationRun: ModelEvaluationRun | undefined; readonly modelEvaluationRun: ModelEvaluationRun | undefined;
} }
interface SelectedMethodDetails { export interface SelectedMethodDetails {
readonly databaseItem: DatabaseItem; readonly databaseItem: DatabaseItem;
readonly method: Method; readonly method: Method;
readonly usage: Usage | undefined; readonly usage: Usage | undefined;

View File

@@ -3,6 +3,7 @@ import type { ModelingEvents } from "../../../src/model-editor/modeling-events";
export function createMockModelingEvents({ export function createMockModelingEvents({
onActiveDbChanged = jest.fn(), onActiveDbChanged = jest.fn(),
onDbOpened = jest.fn(),
onDbClosed = jest.fn(), onDbClosed = jest.fn(),
onSelectedMethodChanged = jest.fn(), onSelectedMethodChanged = jest.fn(),
onMethodsChanged = jest.fn(), onMethodsChanged = jest.fn(),
@@ -16,6 +17,7 @@ export function createMockModelingEvents({
onModelEvaluationRunChanged = jest.fn(), onModelEvaluationRunChanged = jest.fn(),
}: { }: {
onActiveDbChanged?: ModelingEvents["onActiveDbChanged"]; onActiveDbChanged?: ModelingEvents["onActiveDbChanged"];
onDbOpened?: ModelingEvents["onDbOpened"];
onDbClosed?: ModelingEvents["onDbClosed"]; onDbClosed?: ModelingEvents["onDbClosed"];
onSelectedMethodChanged?: ModelingEvents["onSelectedMethodChanged"]; onSelectedMethodChanged?: ModelingEvents["onSelectedMethodChanged"];
onMethodsChanged?: ModelingEvents["onMethodsChanged"]; onMethodsChanged?: ModelingEvents["onMethodsChanged"];
@@ -30,6 +32,7 @@ export function createMockModelingEvents({
} = {}): ModelingEvents { } = {}): ModelingEvents {
return mockedObject<ModelingEvents>({ return mockedObject<ModelingEvents>({
onActiveDbChanged, onActiveDbChanged,
onDbOpened,
onDbClosed, onDbClosed,
onSelectedMethodChanged, onSelectedMethodChanged,
onMethodsChanged, onMethodsChanged,

View File

@@ -3,17 +3,20 @@ import type { ModelingStore } from "../../../src/model-editor/modeling-store";
export function createMockModelingStore({ export function createMockModelingStore({
initializeStateForDb = jest.fn(), initializeStateForDb = jest.fn(),
getStateForActiveDb = jest.fn(), getStateForActiveDb = jest.fn().mockReturnValue(undefined),
getSelectedMethodDetails = jest.fn().mockReturnValue(undefined),
getModelEvaluationRun = jest.fn(), getModelEvaluationRun = jest.fn(),
updateModelEvaluationRun = jest.fn(), updateModelEvaluationRun = jest.fn(),
}: { }: {
initializeStateForDb?: ModelingStore["initializeStateForDb"]; initializeStateForDb?: ModelingStore["initializeStateForDb"];
getStateForActiveDb?: ModelingStore["getStateForActiveDb"]; getStateForActiveDb?: ModelingStore["getStateForActiveDb"];
getSelectedMethodDetails?: ModelingStore["getSelectedMethodDetails"];
getModelEvaluationRun?: ModelingStore["getModelEvaluationRun"]; getModelEvaluationRun?: ModelingStore["getModelEvaluationRun"];
updateModelEvaluationRun?: ModelingStore["updateModelEvaluationRun"]; updateModelEvaluationRun?: ModelingStore["updateModelEvaluationRun"];
} = {}): ModelingStore { } = {}): ModelingStore {
return mockedObject<ModelingStore>({ return mockedObject<ModelingStore>({
initializeStateForDb, initializeStateForDb,
getSelectedMethodDetails,
getStateForActiveDb, getStateForActiveDb,
getModelEvaluationRun, getModelEvaluationRun,
updateModelEvaluationRun, updateModelEvaluationRun,

View File

@@ -14,11 +14,12 @@ export function mockDbOptions(): FullDatabaseOptions {
origin: { origin: {
type: "folder", type: "folder",
}, },
extensionManagedLocation: undefined,
}; };
} }
export function createMockDB( export function createMockDB(
dir: DirResult, dir: DirResult | string,
dbOptions = mockDbOptions(), dbOptions = mockDbOptions(),
// source archive location must be a real(-ish) location since // source archive location must be a real(-ish) location since
// tests will add this to the workspace location // tests will add this to the workspace location
@@ -38,10 +39,18 @@ export function createMockDB(
); );
} }
export function sourceLocationUri(dir: DirResult) { export function sourceLocationUri(dir: DirResult | string) {
if (typeof dir === "string") {
return Uri.file(join(dir, "src.zip"));
}
return Uri.file(join(dir.name, "src.zip")); return Uri.file(join(dir.name, "src.zip"));
} }
export function dbLocationUri(dir: DirResult) { export function dbLocationUri(dir: DirResult | string) {
if (typeof dir === "string") {
return Uri.file(join(dir, "db"));
}
return Uri.file(join(dir.name, "db")); return Uri.file(join(dir.name, "db"));
} }

View File

@@ -3,6 +3,7 @@ import { join, parse } from "path";
import { import {
containsPath, containsPath,
findCommonParentDir, findCommonParentDir,
findDirWithFile,
gatherQlFiles, gatherQlFiles,
getDirectoryNamesInsidePath, getDirectoryNamesInsidePath,
pathsEqual, pathsEqual,
@@ -11,7 +12,13 @@ import {
} from "../../../src/common/files"; } from "../../../src/common/files";
import type { DirResult } from "tmp"; import type { DirResult } from "tmp";
import { dirSync } from "tmp"; import { dirSync } from "tmp";
import { ensureDirSync, symlinkSync, writeFileSync } from "fs-extra"; import {
createFileSync,
ensureDirSync,
mkdirSync,
symlinkSync,
writeFileSync,
} from "fs-extra";
import "../../matchers/toEqualPath"; import "../../matchers/toEqualPath";
describe("files", () => { describe("files", () => {
@@ -592,3 +599,52 @@ describe("findCommonParentDir", () => {
expect(commonDir).toEqualPath(dataDir); expect(commonDir).toEqualPath(dataDir);
}); });
}); });
describe("findDirWithFile", () => {
let dir: DirResult;
beforeEach(() => {
dir = dirSync({ unsafeCleanup: true });
createFile("a");
createFile("b");
createFile("c");
createDir("dir1");
createFile("dir1", "d");
createFile("dir1", "e");
createFile("dir1", "f");
createDir("dir2");
createFile("dir2", "g");
createFile("dir2", "h");
createFile("dir2", "i");
createDir("dir2", "dir3");
createFile("dir2", "dir3", "j");
createFile("dir2", "dir3", "k");
createFile("dir2", "dir3", "l");
});
it("should find files", async () => {
expect(await findDirWithFile(dir.name, "k")).toBe(
join(dir.name, "dir2", "dir3"),
);
expect(await findDirWithFile(dir.name, "h")).toBe(join(dir.name, "dir2"));
expect(await findDirWithFile(dir.name, "z", "a")).toBe(dir.name);
// there's some slight indeterminism when more than one name exists
// but in general, this will find files in the current directory before
// finding files in sub-dirs
expect(await findDirWithFile(dir.name, "k", "a")).toBe(dir.name);
});
it("should not find files", async () => {
expect(await findDirWithFile(dir.name, "x", "y", "z")).toBeUndefined();
});
function createFile(...segments: string[]) {
createFileSync(join(dir.name, ...segments));
}
function createDir(...segments: string[]) {
mkdirSync(join(dir.name, ...segments));
}
});

View File

@@ -242,6 +242,37 @@ describe("local databases", () => {
await expect(pathExists(mockDbItem.databaseUri.fsPath)).resolves.toBe( await expect(pathExists(mockDbItem.databaseUri.fsPath)).resolves.toBe(
false, false,
); );
await expect(pathExists(dir.name)).resolves.toBe(true);
});
it("should remove a database item with an extension managed location", async () => {
const dbLocation = join(dir.name, "org-repo-12");
await ensureDir(dbLocation);
const mockDbItem = createMockDB(dbLocation, {
...mockDbOptions(),
extensionManagedLocation: dbLocation,
});
await ensureDir(mockDbItem.databaseUri.fsPath);
// pretend that this item is the first workspace folder in the list
jest
.spyOn(mockDbItem, "belongsToSourceArchiveExplorerUri")
.mockReturnValue(true);
await (databaseManager as any).addDatabaseItem(mockDbItem);
updateSpy.mockClear();
await databaseManager.removeDatabaseItem(mockDbItem);
expect(databaseManager.databaseItems).toEqual([]);
expect(updateSpy).toHaveBeenCalledWith("databaseList", []);
// should remove the folder
expect(workspace.updateWorkspaceFolders).toHaveBeenCalledWith(0, 1);
// should delete the complete extension managed location
await expect(pathExists(dbLocation)).resolves.toBe(false);
}); });
it("should remove a database item outside of the extension controlled area", async () => { it("should remove a database item outside of the extension controlled area", async () => {
@@ -604,6 +635,7 @@ describe("local databases", () => {
origin: { origin: {
type: "folder", type: "folder",
}, },
extensionManagedLocation: undefined,
}; };
mockDbItem = createMockDB(dir, options); mockDbItem = createMockDB(dir, options);

View File

@@ -1,253 +0,0 @@
import { join } from "path";
import { createFileSync, mkdirSync } from "fs-extra";
import type { DirResult } from "tmp";
import { dirSync } from "tmp";
import { window } from "vscode";
import {
convertGithubNwoToDatabaseUrl,
findDirWithFile,
} from "../../../../src/databases/database-fetcher";
import type { Octokit } from "@octokit/rest";
import {
mockedObject,
mockedOctokitFunction,
mockedQuickPickItem,
} from "../../utils/mocking.helpers";
// These tests make API calls and may need extra time to complete.
jest.setTimeout(10000);
describe("database-fetcher", () => {
describe("convertGithubNwoToDatabaseUrl", () => {
let quickPickSpy: jest.SpiedFunction<typeof window.showQuickPick>;
const progressSpy = jest.fn();
const mockListCodeqlDatabases = mockedOctokitFunction<
"codeScanning",
"listCodeqlDatabases"
>();
const octokit = mockedObject<Octokit>({
rest: {
codeScanning: {
listCodeqlDatabases: mockListCodeqlDatabases,
},
},
});
// We can't make the real octokit request (since we need credentials), so we mock the response.
const successfullMockApiResponse = {
data: [
{
id: 1495869,
name: "csharp-database",
language: "csharp",
uploader: {},
content_type: "application/zip",
state: "uploaded",
size: 55599715,
created_at: "2022-03-24T10:46:24Z",
updated_at: "2022-03-24T10:46:27Z",
url: "https://api.github.com/repositories/143040428/code-scanning/codeql/databases/csharp",
},
{
id: 1100671,
name: "database.zip",
language: "javascript",
uploader: {},
content_type: "application/zip",
state: "uploaded",
size: 29294434,
created_at: "2022-03-01T16:00:04Z",
updated_at: "2022-03-01T16:00:06Z",
url: "https://api.github.com/repositories/143040428/code-scanning/codeql/databases/javascript",
},
{
id: 648738,
name: "ql-database",
language: "ql",
uploader: {},
content_type: "application/json; charset=utf-8",
state: "uploaded",
size: 39735500,
created_at: "2022-02-02T09:38:50Z",
updated_at: "2022-02-02T09:38:51Z",
url: "https://api.github.com/repositories/143040428/code-scanning/codeql/databases/ql",
},
],
};
beforeEach(() => {
quickPickSpy = jest
.spyOn(window, "showQuickPick")
.mockResolvedValue(undefined);
});
it("should convert a GitHub nwo to a database url", async () => {
mockListCodeqlDatabases.mockResolvedValue(successfullMockApiResponse);
quickPickSpy.mockResolvedValue(
mockedQuickPickItem({
label: "JavaScript",
language: "javascript",
}),
);
const githubRepo = "github/codeql";
const result = await convertGithubNwoToDatabaseUrl(
githubRepo,
octokit,
progressSpy,
);
expect(result).toBeDefined();
if (result === undefined) {
return;
}
const { databaseUrl, name, owner } = result;
expect(databaseUrl).toBe(
"https://api.github.com/repositories/143040428/code-scanning/codeql/databases/javascript",
);
expect(name).toBe("codeql");
expect(owner).toBe("github");
expect(quickPickSpy).toHaveBeenNthCalledWith(
1,
[
expect.objectContaining({
label: "C#",
description: "csharp",
language: "csharp",
}),
expect.objectContaining({
label: "JavaScript",
description: "javascript",
language: "javascript",
}),
expect.objectContaining({
label: "ql",
description: "ql",
language: "ql",
}),
],
expect.anything(),
);
});
// Repository doesn't exist, or the user has no access to the repository.
it("should fail on an invalid/inaccessible repository", async () => {
const mockApiResponse = {
data: {
message: "Not Found",
},
status: 404,
};
mockListCodeqlDatabases.mockResolvedValue(mockApiResponse);
const githubRepo = "foo/bar-not-real";
await expect(
convertGithubNwoToDatabaseUrl(githubRepo, octokit, progressSpy),
).rejects.toThrow(/Unable to get database/);
expect(progressSpy).toHaveBeenCalledTimes(0);
});
// User has access to the repository, but there are no databases for any language.
it("should fail on a repository with no databases", async () => {
const mockApiResponse = {
data: [],
};
mockListCodeqlDatabases.mockResolvedValue(mockApiResponse);
const githubRepo = "foo/bar-with-no-dbs";
await expect(
convertGithubNwoToDatabaseUrl(githubRepo, octokit, progressSpy),
).rejects.toThrow(/Unable to get database/);
expect(progressSpy).toHaveBeenCalledTimes(1);
});
describe("when language is already provided", () => {
describe("when language is valid", () => {
it("should not prompt the user", async () => {
mockListCodeqlDatabases.mockResolvedValue(successfullMockApiResponse);
const githubRepo = "github/codeql";
await convertGithubNwoToDatabaseUrl(
githubRepo,
octokit,
progressSpy,
"javascript",
);
expect(quickPickSpy).not.toHaveBeenCalled();
});
});
describe("when language is invalid", () => {
it("should prompt for language", async () => {
mockListCodeqlDatabases.mockResolvedValue(successfullMockApiResponse);
const githubRepo = "github/codeql";
await convertGithubNwoToDatabaseUrl(
githubRepo,
octokit,
progressSpy,
"invalid-language",
);
expect(quickPickSpy).toHaveBeenCalled();
});
});
});
describe("when language is not provided", () => {
it("should prompt for language", async () => {
mockListCodeqlDatabases.mockResolvedValue(successfullMockApiResponse);
const githubRepo = "github/codeql";
await convertGithubNwoToDatabaseUrl(githubRepo, octokit, progressSpy);
expect(quickPickSpy).toHaveBeenCalled();
});
});
});
describe("findDirWithFile", () => {
let dir: DirResult;
beforeEach(() => {
dir = dirSync({ unsafeCleanup: true });
createFile("a");
createFile("b");
createFile("c");
createDir("dir1");
createFile("dir1", "d");
createFile("dir1", "e");
createFile("dir1", "f");
createDir("dir2");
createFile("dir2", "g");
createFile("dir2", "h");
createFile("dir2", "i");
createDir("dir2", "dir3");
createFile("dir2", "dir3", "j");
createFile("dir2", "dir3", "k");
createFile("dir2", "dir3", "l");
});
it("should find files", async () => {
expect(await findDirWithFile(dir.name, "k")).toBe(
join(dir.name, "dir2", "dir3"),
);
expect(await findDirWithFile(dir.name, "h")).toBe(join(dir.name, "dir2"));
expect(await findDirWithFile(dir.name, "z", "a")).toBe(dir.name);
// there's some slight indeterminism when more than one name exists
// but in general, this will find files in the current directory before
// finding files in sub-dirs
expect(await findDirWithFile(dir.name, "k", "a")).toBe(dir.name);
});
it("should not find files", async () => {
expect(await findDirWithFile(dir.name, "x", "y", "z")).toBeUndefined();
});
function createFile(...segments: string[]) {
createFileSync(join(dir.name, ...segments));
}
function createDir(...segments: string[]) {
mkdirSync(join(dir.name, ...segments));
}
});
});

View File

@@ -1,14 +1,19 @@
import { import {
mockedObject, mockedObject,
mockedOctokitFunction, mockedOctokitFunction,
mockedQuickPickItem,
} from "../../../utils/mocking.helpers"; } from "../../../utils/mocking.helpers";
import type { GitHubDatabaseConfig } from "../../../../../src/config"; import type { GitHubDatabaseConfig } from "../../../../../src/config";
import * as dialog from "../../../../../src/common/vscode/dialog"; import * as dialog from "../../../../../src/common/vscode/dialog";
import { listDatabases } from "../../../../../src/databases/github-databases/api"; import {
convertGithubNwoToDatabaseUrl,
listDatabases,
} from "../../../../../src/databases/github-databases/api";
import type { Credentials } from "../../../../../src/common/authentication"; import type { Credentials } from "../../../../../src/common/authentication";
import type { Octokit } from "@octokit/rest"; import type { Octokit } from "@octokit/rest";
import { AppOctokit } from "../../../../../src/common/octokit"; import { AppOctokit } from "../../../../../src/common/octokit";
import { RequestError } from "@octokit/request-error"; import { RequestError } from "@octokit/request-error";
import { window } from "vscode";
// Mock the AppOctokit constructor to ensure we aren't making any network requests // Mock the AppOctokit constructor to ensure we aren't making any network requests
jest.mock("../../../../../src/common/octokit", () => ({ jest.mock("../../../../../src/common/octokit", () => ({
@@ -349,3 +354,186 @@ describe("listDatabases", () => {
}); });
}); });
}); });
describe("convertGithubNwoToDatabaseUrl", () => {
let quickPickSpy: jest.SpiedFunction<typeof window.showQuickPick>;
const progressSpy = jest.fn();
const mockListCodeqlDatabases = mockedOctokitFunction<
"codeScanning",
"listCodeqlDatabases"
>();
const octokit = mockedObject<Octokit>({
rest: {
codeScanning: {
listCodeqlDatabases: mockListCodeqlDatabases,
},
},
});
// We can't make the real octokit request (since we need credentials), so we mock the response.
const successfullMockApiResponse = {
data: [
{
id: 1495869,
name: "csharp-database",
language: "csharp",
uploader: {},
content_type: "application/zip",
state: "uploaded",
size: 55599715,
created_at: "2022-03-24T10:46:24Z",
updated_at: "2022-03-24T10:46:27Z",
url: "https://api.github.com/repositories/143040428/code-scanning/codeql/databases/csharp",
},
{
id: 1100671,
name: "database.zip",
language: "javascript",
uploader: {},
content_type: "application/zip",
state: "uploaded",
size: 29294434,
created_at: "2022-03-01T16:00:04Z",
updated_at: "2022-03-01T16:00:06Z",
url: "https://api.github.com/repositories/143040428/code-scanning/codeql/databases/javascript",
},
{
id: 648738,
name: "ql-database",
language: "ql",
uploader: {},
content_type: "application/json; charset=utf-8",
state: "uploaded",
size: 39735500,
created_at: "2022-02-02T09:38:50Z",
updated_at: "2022-02-02T09:38:51Z",
url: "https://api.github.com/repositories/143040428/code-scanning/codeql/databases/ql",
},
],
};
beforeEach(() => {
quickPickSpy = jest
.spyOn(window, "showQuickPick")
.mockResolvedValue(undefined);
});
it("should convert a GitHub nwo to a database url", async () => {
mockListCodeqlDatabases.mockResolvedValue(successfullMockApiResponse);
quickPickSpy.mockResolvedValue(
mockedQuickPickItem({
label: "JavaScript",
language: "javascript",
}),
);
const githubRepo = "github/codeql";
const result = await convertGithubNwoToDatabaseUrl(
githubRepo,
octokit,
progressSpy,
);
expect(result).toBeDefined();
if (result === undefined) {
return;
}
const { databaseUrl, name, owner } = result;
expect(databaseUrl).toBe(
"https://api.github.com/repositories/143040428/code-scanning/codeql/databases/javascript",
);
expect(name).toBe("codeql");
expect(owner).toBe("github");
expect(quickPickSpy).toHaveBeenNthCalledWith(
1,
[
expect.objectContaining({
label: "C#",
description: "csharp",
language: "csharp",
}),
expect.objectContaining({
label: "JavaScript",
description: "javascript",
language: "javascript",
}),
expect.objectContaining({
label: "ql",
description: "ql",
language: "ql",
}),
],
expect.anything(),
);
});
// Repository doesn't exist, or the user has no access to the repository.
it("should fail on an invalid/inaccessible repository", async () => {
const mockApiResponse = {
data: {
message: "Not Found",
},
status: 404,
};
mockListCodeqlDatabases.mockResolvedValue(mockApiResponse);
const githubRepo = "foo/bar-not-real";
await expect(
convertGithubNwoToDatabaseUrl(githubRepo, octokit, progressSpy),
).rejects.toThrow(/Unable to get database/);
expect(progressSpy).toHaveBeenCalledTimes(0);
});
// User has access to the repository, but there are no databases for any language.
it("should fail on a repository with no databases", async () => {
const mockApiResponse = {
data: [],
};
mockListCodeqlDatabases.mockResolvedValue(mockApiResponse);
const githubRepo = "foo/bar-with-no-dbs";
await expect(
convertGithubNwoToDatabaseUrl(githubRepo, octokit, progressSpy),
).rejects.toThrow(/Unable to get database/);
expect(progressSpy).toHaveBeenCalledTimes(1);
});
describe("when language is already provided", () => {
describe("when language is valid", () => {
it("should not prompt the user", async () => {
mockListCodeqlDatabases.mockResolvedValue(successfullMockApiResponse);
const githubRepo = "github/codeql";
await convertGithubNwoToDatabaseUrl(
githubRepo,
octokit,
progressSpy,
"javascript",
);
expect(quickPickSpy).not.toHaveBeenCalled();
});
});
describe("when language is invalid", () => {
it("should prompt for language", async () => {
mockListCodeqlDatabases.mockResolvedValue(successfullMockApiResponse);
const githubRepo = "github/codeql";
await convertGithubNwoToDatabaseUrl(
githubRepo,
octokit,
progressSpy,
"invalid-language",
);
expect(quickPickSpy).toHaveBeenCalled();
});
});
});
describe("when language is not provided", () => {
it("should prompt for language", async () => {
mockListCodeqlDatabases.mockResolvedValue(successfullMockApiResponse);
const githubRepo = "github/codeql";
await convertGithubNwoToDatabaseUrl(githubRepo, octokit, progressSpy);
expect(quickPickSpy).toHaveBeenCalled();
});
});
});

View File

@@ -0,0 +1,205 @@
import type { Uri, Webview, WebviewView } from "vscode";
import { EventEmitter } from "vscode";
import type { ModelConfigListener } from "../../../../../src/config";
import { MethodModelingViewProvider } from "../../../../../src/model-editor/method-modeling/method-modeling-view-provider";
import { createMockApp } from "../../../../__mocks__/appMock";
import { createMockModelingEvents } from "../../../../__mocks__/model-editor/modelingEventsMock";
import { createMockModelingStore } from "../../../../__mocks__/model-editor/modelingStoreMock";
import { mockedObject } from "../../../../mocked-object";
import type { FromMethodModelingMessage } from "../../../../../src/common/interface-types";
import { DisposableObject } from "../../../../../src/common/disposable-object";
import type { ModelingEvents } from "../../../../../src/model-editor/modeling-events";
import type {
DbModelingState,
ModelingStore,
SelectedMethodDetails,
} from "../../../../../src/model-editor/modeling-store";
import { mockDatabaseItem } from "../../../utils/mocking.helpers";
import {
createMethod,
createUsage,
} from "../../../../factories/model-editor/method-factories";
describe("method modeling view provider", () => {
// Modeling store
let getStateForActiveDb: jest.MockedFunction<
ModelingStore["getStateForActiveDb"]
>;
let getSelectedMethodDetails: jest.MockedFunction<
ModelingStore["getSelectedMethodDetails"]
>;
// Modeling events
let selectedMethodChangedEventEmitter: ModelingEvents["onSelectedMethodChangedEventEmitter"];
let dbOpenedEventEmitter: ModelingEvents["onDbOpenedEventEmitter"];
// View provider
let viewProvider: MethodModelingViewProvider;
let onDidReceiveMessage: (msg: FromMethodModelingMessage) => Promise<void>;
let postMessage: (message: unknown) => Promise<boolean>;
beforeEach(async () => {
const app = createMockApp({});
getStateForActiveDb = jest.fn().mockReturnValue(undefined);
getSelectedMethodDetails = jest.fn().mockReturnValue(undefined);
const modelingStore = createMockModelingStore({
getStateForActiveDb,
getSelectedMethodDetails,
});
selectedMethodChangedEventEmitter = new EventEmitter();
dbOpenedEventEmitter = new EventEmitter();
const modelingEvents = createMockModelingEvents({
onSelectedMethodChanged: selectedMethodChangedEventEmitter.event,
onDbOpened: dbOpenedEventEmitter.event,
});
const modelConfigListener = mockedObject<ModelConfigListener>({
showTypeModels: true,
onDidChangeConfiguration: jest.fn(),
});
viewProvider = new MethodModelingViewProvider(
app,
modelingStore,
modelingEvents,
modelConfigListener,
);
postMessage = jest.fn().mockResolvedValue(true);
const webview: Webview = {
options: {},
html: "",
onDidReceiveMessage: (listener) => {
onDidReceiveMessage = listener;
return new DisposableObject();
},
postMessage,
asWebviewUri: (uri: Uri) => uri,
cspSource: "",
};
const webviewView = mockedObject<WebviewView>({
webview,
onDidDispose: jest.fn(),
});
viewProvider.resolveWebviewView(webviewView);
expect(onDidReceiveMessage).toBeDefined();
});
it("should load webview when no active DB", async () => {
await onDidReceiveMessage({
t: "viewLoaded",
viewName: MethodModelingViewProvider.viewType,
});
expect(postMessage).toHaveBeenCalledTimes(1);
expect(postMessage).toHaveBeenCalledWith({
t: "setMethodModelingPanelViewState",
viewState: {
language: undefined,
modelConfig: {
showTypeModels: true,
},
},
});
});
it("should load webview when active DB but no selected method", async () => {
const dbModelingState = mockedObject<DbModelingState>({
databaseItem: mockDatabaseItem({
language: "java",
}),
});
getStateForActiveDb.mockReturnValue(dbModelingState);
await onDidReceiveMessage({
t: "viewLoaded",
viewName: MethodModelingViewProvider.viewType,
});
expect(postMessage).toHaveBeenCalledTimes(3);
expect(postMessage).toHaveBeenNthCalledWith(1, {
t: "setMethodModelingPanelViewState",
viewState: {
language: undefined,
modelConfig: {
showTypeModels: true,
},
},
});
expect(postMessage).toHaveBeenNthCalledWith(2, {
t: "setInModelingMode",
inModelingMode: true,
});
expect(postMessage).toHaveBeenNthCalledWith(3, {
t: "setMethodModelingPanelViewState",
viewState: {
language: "java",
modelConfig: {
showTypeModels: true,
},
},
});
});
it("should load webview when active DB and a selected method", async () => {
const dbModelingState = mockedObject<DbModelingState>({
databaseItem: mockDatabaseItem({
language: "java",
}),
});
getStateForActiveDb.mockReturnValue(dbModelingState);
const selectedMethodDetails: SelectedMethodDetails = {
databaseItem: dbModelingState.databaseItem,
method: createMethod(),
usage: createUsage(),
modeledMethods: [],
isModified: false,
isInProgress: false,
processedByAutoModel: false,
};
getSelectedMethodDetails.mockReturnValue(selectedMethodDetails);
await onDidReceiveMessage({
t: "viewLoaded",
viewName: MethodModelingViewProvider.viewType,
});
expect(postMessage).toHaveBeenCalledTimes(4);
expect(postMessage).toHaveBeenNthCalledWith(1, {
t: "setMethodModelingPanelViewState",
viewState: {
language: undefined,
modelConfig: {
showTypeModels: true,
},
},
});
expect(postMessage).toHaveBeenNthCalledWith(2, {
t: "setInModelingMode",
inModelingMode: true,
});
expect(postMessage).toHaveBeenNthCalledWith(3, {
t: "setMethodModelingPanelViewState",
viewState: {
language: "java",
modelConfig: {
showTypeModels: true,
},
},
});
expect(postMessage).toHaveBeenNthCalledWith(4, {
t: "setSelectedMethod",
method: selectedMethodDetails.method,
modeledMethods: selectedMethodDetails.modeledMethods,
isModified: selectedMethodDetails.isModified,
isInProgress: selectedMethodDetails.isInProgress,
processedByAutoModel: selectedMethodDetails.processedByAutoModel,
});
});
});