diff --git a/extensions/ql-vscode/src/common/files.ts b/extensions/ql-vscode/src/common/files.ts index 80b59a2e9..2e631ce6f 100644 --- a/extensions/ql-vscode/src/common/files.ts +++ b/extensions/ql-vscode/src/common/files.ts @@ -176,3 +176,32 @@ export function findCommonParentDir(...paths: string[]): string { function isTopLevelPath(path: string): boolean { 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 { + 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; +} diff --git a/extensions/ql-vscode/src/common/vscode/abstract-webview-view-provider.ts b/extensions/ql-vscode/src/common/vscode/abstract-webview-view-provider.ts index d82ede247..37181e5c5 100644 --- a/extensions/ql-vscode/src/common/vscode/abstract-webview-view-provider.ts +++ b/extensions/ql-vscode/src/common/vscode/abstract-webview-view-provider.ts @@ -1,9 +1,4 @@ -import type { - CancellationToken, - WebviewView, - WebviewViewProvider, - WebviewViewResolveContext, -} from "vscode"; +import type { WebviewView, WebviewViewProvider } from "vscode"; import { Uri } from "vscode"; import type { WebviewKind, WebviewMessage } 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 * first loaded or when the user hides and then shows a view again. */ - public resolveWebviewView( - webviewView: WebviewView, - _context: WebviewViewResolveContext, - _token: CancellationToken, - ) { + public resolveWebviewView(webviewView: WebviewView) { webviewView.webview.options = { enableScripts: true, localResourceRoots: [Uri.file(this.app.extensionPath)], diff --git a/extensions/ql-vscode/src/databases/database-fetcher.ts b/extensions/ql-vscode/src/databases/database-fetcher.ts index fde9fc73e..b8b2f5077 100644 --- a/extensions/ql-vscode/src/databases/database-fetcher.ts +++ b/extensions/ql-vscode/src/databases/database-fetcher.ts @@ -10,7 +10,6 @@ import { pathExists, createWriteStream, remove, - stat, readdir, } from "fs-extra"; import { basename, join } from "path"; @@ -36,11 +35,12 @@ import { } from "../config"; import { showAndLogInformationMessage } from "../common/logging"; import { AppOctokit } from "../common/octokit"; -import { getLanguageDisplayName } from "../common/query-language"; import type { DatabaseOrigin } from "./local-databases/database-origin"; import { createTimeoutSignal } from "../common/fetch-stream"; import type { App } from "../common/app"; 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. @@ -406,6 +406,7 @@ async function databaseArchiveFetcher( nameOverride, { addSourceArchiveFolder, + extensionManagedLocation: unzipPath, }, ); return item; @@ -617,125 +618,6 @@ function isFile(databaseUrl: string) { 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 { - 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 { - 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 * source location. However, this extension works better if sources diff --git a/extensions/ql-vscode/src/databases/github-databases/api.ts b/extensions/ql-vscode/src/databases/github-databases/api.ts index bfcffa9dd..186b1a9c2 100644 --- a/extensions/ql-vscode/src/databases/github-databases/api.ts +++ b/extensions/ql-vscode/src/databases/github-databases/api.ts @@ -5,6 +5,11 @@ import { showNeverAskAgainDialog } from "../../common/vscode/dialog"; import type { GitHubDatabaseConfig } from "../../config"; import type { Credentials } from "../../common/authentication"; 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 = RestEndpointMethodTypes["codeScanning"]["listCodeqlDatabases"]["response"]["data"][number]; @@ -108,3 +113,92 @@ export async function listDatabases( 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 { + 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; +} diff --git a/extensions/ql-vscode/src/databases/local-databases/database-item-impl.ts b/extensions/ql-vscode/src/databases/local-databases/database-item-impl.ts index a2d846c76..dab6c01d9 100644 --- a/extensions/ql-vscode/src/databases/local-databases/database-item-impl.ts +++ b/extensions/ql-vscode/src/databases/local-databases/database-item-impl.ts @@ -66,6 +66,10 @@ export class DatabaseItemImpl implements DatabaseItem { return this.options.origin; } + public get extensionManagedLocation(): string | undefined { + return this.options.extensionManagedLocation; + } + public resolveSourceFile(uriStr: string | undefined): Uri { const sourceArchive = this.sourceArchive; const uri = uriStr ? Uri.parse(uriStr, true) : undefined; diff --git a/extensions/ql-vscode/src/databases/local-databases/database-item.ts b/extensions/ql-vscode/src/databases/local-databases/database-item.ts index 7feec4789..72f5a37ee 100644 --- a/extensions/ql-vscode/src/databases/local-databases/database-item.ts +++ b/extensions/ql-vscode/src/databases/local-databases/database-item.ts @@ -31,6 +31,12 @@ export interface DatabaseItem { */ 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. */ readonly error: Error | undefined; diff --git a/extensions/ql-vscode/src/databases/local-databases/database-manager.ts b/extensions/ql-vscode/src/databases/local-databases/database-manager.ts index c78a91b91..9454355e6 100644 --- a/extensions/ql-vscode/src/databases/local-databases/database-manager.ts +++ b/extensions/ql-vscode/src/databases/local-databases/database-manager.ts @@ -82,6 +82,10 @@ function eventFired( } type OpenDatabaseOptions = { + /** + * A location that is managed by the extension. + */ + extensionManagedLocation?: string; isTutorialDatabase?: boolean; /** * 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, displayName?: string, { + extensionManagedLocation, isTutorialDatabase = false, addSourceArchiveFolder = addDatabaseSourceToWorkspace(), }: OpenDatabaseOptions = {}, @@ -149,6 +154,7 @@ export class DatabaseManager extends DisposableObject { uri, origin, displayName, + extensionManagedLocation, ); return await this.addExistingDatabaseItem( @@ -202,6 +208,7 @@ export class DatabaseManager extends DisposableObject { uri: vscode.Uri, origin: DatabaseOrigin | undefined, displayName: string | undefined, + extensionManagedLocation?: string, ): Promise { const contents = await DatabaseResolver.resolveDatabaseContents(uri); const fullOptions: FullDatabaseOptions = { @@ -210,6 +217,7 @@ export class DatabaseManager extends DisposableObject { dateAdded: Date.now(), language: await this.getPrimaryLanguage(uri.fsPath), origin, + extensionManagedLocation, }; const databaseItem = new DatabaseItemImpl(uri, contents, fullOptions); @@ -370,6 +378,7 @@ export class DatabaseManager extends DisposableObject { let dateAdded = undefined; let language = undefined; let origin = undefined; + let extensionManagedLocation = undefined; if (state.options) { if (typeof state.options.displayName === "string") { displayName = state.options.displayName; @@ -379,6 +388,7 @@ export class DatabaseManager extends DisposableObject { } language = state.options.language; origin = state.options.origin; + extensionManagedLocation = state.options.extensionManagedLocation; } const dbBaseUri = vscode.Uri.parse(state.uri, true); @@ -392,6 +402,7 @@ export class DatabaseManager extends DisposableObject { dateAdded, language, origin, + extensionManagedLocation, }; const item = new DatabaseItemImpl(dbBaseUri, undefined, fullOptions); @@ -583,15 +594,20 @@ export class DatabaseManager extends DisposableObject { // Remove this database item from the allow-list 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 - if (this.isExtensionControlledLocation(item.databaseUri)) { + if (this.isExtensionControlledLocation(directoryToRemove)) { void extLogger.log("Deleting database from filesystem."); - await remove(item.databaseUri.fsPath).then( - () => void extLogger.log(`Deleted '${item.databaseUri.fsPath}'`), + await remove(directoryToRemove.fsPath).then( + () => void extLogger.log(`Deleted '${directoryToRemove.fsPath}'`), (e: unknown) => void extLogger.log( `Failed to delete '${ - item.databaseUri.fsPath + directoryToRemove.fsPath }'. Reason: ${getErrorMessage(e)}`, ), ); diff --git a/extensions/ql-vscode/src/databases/local-databases/database-options.ts b/extensions/ql-vscode/src/databases/local-databases/database-options.ts index 68146d166..25b8e6a4b 100644 --- a/extensions/ql-vscode/src/databases/local-databases/database-options.ts +++ b/extensions/ql-vscode/src/databases/local-databases/database-options.ts @@ -5,10 +5,12 @@ export interface DatabaseOptions { dateAdded?: number | undefined; language?: string; origin?: DatabaseOrigin; + extensionManagedLocation?: string; } export interface FullDatabaseOptions extends DatabaseOptions { dateAdded: number | undefined; language: string | undefined; origin: DatabaseOrigin | undefined; + extensionManagedLocation: string | undefined; } diff --git a/extensions/ql-vscode/src/model-editor/modeling-store.ts b/extensions/ql-vscode/src/model-editor/modeling-store.ts index 643b0b2a0..362275937 100644 --- a/extensions/ql-vscode/src/model-editor/modeling-store.ts +++ b/extensions/ql-vscode/src/model-editor/modeling-store.ts @@ -22,7 +22,7 @@ interface InternalDbModelingState { modelEvaluationRun: ModelEvaluationRun | undefined; } -interface DbModelingState { +export interface DbModelingState { readonly databaseItem: DatabaseItem; readonly methods: readonly Method[]; readonly hideModeledMethods: boolean; @@ -36,7 +36,7 @@ interface DbModelingState { readonly modelEvaluationRun: ModelEvaluationRun | undefined; } -interface SelectedMethodDetails { +export interface SelectedMethodDetails { readonly databaseItem: DatabaseItem; readonly method: Method; readonly usage: Usage | undefined; diff --git a/extensions/ql-vscode/test/__mocks__/model-editor/modelingEventsMock.ts b/extensions/ql-vscode/test/__mocks__/model-editor/modelingEventsMock.ts index e307cd1ef..04db82f8e 100644 --- a/extensions/ql-vscode/test/__mocks__/model-editor/modelingEventsMock.ts +++ b/extensions/ql-vscode/test/__mocks__/model-editor/modelingEventsMock.ts @@ -3,6 +3,7 @@ import type { ModelingEvents } from "../../../src/model-editor/modeling-events"; export function createMockModelingEvents({ onActiveDbChanged = jest.fn(), + onDbOpened = jest.fn(), onDbClosed = jest.fn(), onSelectedMethodChanged = jest.fn(), onMethodsChanged = jest.fn(), @@ -16,6 +17,7 @@ export function createMockModelingEvents({ onModelEvaluationRunChanged = jest.fn(), }: { onActiveDbChanged?: ModelingEvents["onActiveDbChanged"]; + onDbOpened?: ModelingEvents["onDbOpened"]; onDbClosed?: ModelingEvents["onDbClosed"]; onSelectedMethodChanged?: ModelingEvents["onSelectedMethodChanged"]; onMethodsChanged?: ModelingEvents["onMethodsChanged"]; @@ -30,6 +32,7 @@ export function createMockModelingEvents({ } = {}): ModelingEvents { return mockedObject({ onActiveDbChanged, + onDbOpened, onDbClosed, onSelectedMethodChanged, onMethodsChanged, diff --git a/extensions/ql-vscode/test/__mocks__/model-editor/modelingStoreMock.ts b/extensions/ql-vscode/test/__mocks__/model-editor/modelingStoreMock.ts index 7cbad68c6..8bfbd28f4 100644 --- a/extensions/ql-vscode/test/__mocks__/model-editor/modelingStoreMock.ts +++ b/extensions/ql-vscode/test/__mocks__/model-editor/modelingStoreMock.ts @@ -3,17 +3,20 @@ import type { ModelingStore } from "../../../src/model-editor/modeling-store"; export function createMockModelingStore({ initializeStateForDb = jest.fn(), - getStateForActiveDb = jest.fn(), + getStateForActiveDb = jest.fn().mockReturnValue(undefined), + getSelectedMethodDetails = jest.fn().mockReturnValue(undefined), getModelEvaluationRun = jest.fn(), updateModelEvaluationRun = jest.fn(), }: { initializeStateForDb?: ModelingStore["initializeStateForDb"]; getStateForActiveDb?: ModelingStore["getStateForActiveDb"]; + getSelectedMethodDetails?: ModelingStore["getSelectedMethodDetails"]; getModelEvaluationRun?: ModelingStore["getModelEvaluationRun"]; updateModelEvaluationRun?: ModelingStore["updateModelEvaluationRun"]; } = {}): ModelingStore { return mockedObject({ initializeStateForDb, + getSelectedMethodDetails, getStateForActiveDb, getModelEvaluationRun, updateModelEvaluationRun, diff --git a/extensions/ql-vscode/test/factories/databases/databases.ts b/extensions/ql-vscode/test/factories/databases/databases.ts index e4a970791..6db032dbe 100644 --- a/extensions/ql-vscode/test/factories/databases/databases.ts +++ b/extensions/ql-vscode/test/factories/databases/databases.ts @@ -14,11 +14,12 @@ export function mockDbOptions(): FullDatabaseOptions { origin: { type: "folder", }, + extensionManagedLocation: undefined, }; } export function createMockDB( - dir: DirResult, + dir: DirResult | string, dbOptions = mockDbOptions(), // source archive location must be a real(-ish) location since // 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")); } -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")); } diff --git a/extensions/ql-vscode/test/unit-tests/common/files.test.ts b/extensions/ql-vscode/test/unit-tests/common/files.test.ts index 789b5d52f..9c435a3b4 100644 --- a/extensions/ql-vscode/test/unit-tests/common/files.test.ts +++ b/extensions/ql-vscode/test/unit-tests/common/files.test.ts @@ -3,6 +3,7 @@ import { join, parse } from "path"; import { containsPath, findCommonParentDir, + findDirWithFile, gatherQlFiles, getDirectoryNamesInsidePath, pathsEqual, @@ -11,7 +12,13 @@ import { } from "../../../src/common/files"; import type { DirResult } 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"; describe("files", () => { @@ -592,3 +599,52 @@ describe("findCommonParentDir", () => { 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)); + } +}); diff --git a/extensions/ql-vscode/test/vscode-tests/minimal-workspace/local-queries/local-databases.test.ts b/extensions/ql-vscode/test/vscode-tests/minimal-workspace/local-queries/local-databases.test.ts index 1fdfa67db..25c560e80 100644 --- a/extensions/ql-vscode/test/vscode-tests/minimal-workspace/local-queries/local-databases.test.ts +++ b/extensions/ql-vscode/test/vscode-tests/minimal-workspace/local-queries/local-databases.test.ts @@ -242,6 +242,37 @@ describe("local databases", () => { await expect(pathExists(mockDbItem.databaseUri.fsPath)).resolves.toBe( 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 () => { @@ -604,6 +635,7 @@ describe("local databases", () => { origin: { type: "folder", }, + extensionManagedLocation: undefined, }; mockDbItem = createMockDB(dir, options); diff --git a/extensions/ql-vscode/test/vscode-tests/no-workspace/databases/database-fetcher.test.ts b/extensions/ql-vscode/test/vscode-tests/no-workspace/databases/database-fetcher.test.ts deleted file mode 100644 index 4d38cb81e..000000000 --- a/extensions/ql-vscode/test/vscode-tests/no-workspace/databases/database-fetcher.test.ts +++ /dev/null @@ -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; - - const progressSpy = jest.fn(); - const mockListCodeqlDatabases = mockedOctokitFunction< - "codeScanning", - "listCodeqlDatabases" - >(); - const octokit = mockedObject({ - 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)); - } - }); -}); diff --git a/extensions/ql-vscode/test/vscode-tests/no-workspace/databases/github-databases/api.test.ts b/extensions/ql-vscode/test/vscode-tests/no-workspace/databases/github-databases/api.test.ts index f5dbadd6c..ed1e9f031 100644 --- a/extensions/ql-vscode/test/vscode-tests/no-workspace/databases/github-databases/api.test.ts +++ b/extensions/ql-vscode/test/vscode-tests/no-workspace/databases/github-databases/api.test.ts @@ -1,14 +1,19 @@ import { mockedObject, mockedOctokitFunction, + mockedQuickPickItem, } from "../../../utils/mocking.helpers"; import type { GitHubDatabaseConfig } from "../../../../../src/config"; 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 { Octokit } from "@octokit/rest"; import { AppOctokit } from "../../../../../src/common/octokit"; import { RequestError } from "@octokit/request-error"; +import { window } from "vscode"; // Mock the AppOctokit constructor to ensure we aren't making any network requests jest.mock("../../../../../src/common/octokit", () => ({ @@ -349,3 +354,186 @@ describe("listDatabases", () => { }); }); }); + +describe("convertGithubNwoToDatabaseUrl", () => { + let quickPickSpy: jest.SpiedFunction; + + const progressSpy = jest.fn(); + const mockListCodeqlDatabases = mockedOctokitFunction< + "codeScanning", + "listCodeqlDatabases" + >(); + const octokit = mockedObject({ + 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(); + }); + }); +}); diff --git a/extensions/ql-vscode/test/vscode-tests/no-workspace/model-editor/method-modeling/method-modeling-view-provider.test.ts b/extensions/ql-vscode/test/vscode-tests/no-workspace/model-editor/method-modeling/method-modeling-view-provider.test.ts new file mode 100644 index 000000000..6e2c555f8 --- /dev/null +++ b/extensions/ql-vscode/test/vscode-tests/no-workspace/model-editor/method-modeling/method-modeling-view-provider.test.ts @@ -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; + let postMessage: (message: unknown) => Promise; + + 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({ + 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({ + 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({ + 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({ + 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, + }); + }); +});