Merge remote-tracking branch 'origin/main' into koesie10/show-extension-pack-name
This commit is contained in:
@@ -2,6 +2,8 @@
|
||||
|
||||
## [UNRELEASED]
|
||||
|
||||
- Add new configuration option to allow downloading databases from http, non-secure servers. [#2332](https://github.com/github/vscode-codeql/pull/2332)
|
||||
|
||||
## 1.8.2 - 12 April 2023
|
||||
|
||||
- Fix bug where users could end up with the managed CodeQL CLI getting uninstalled during upgrades and not reinstalled. [#2294](https://github.com/github/vscode-codeql/pull/2294)
|
||||
|
||||
4070
extensions/ql-vscode/package-lock.json
generated
4070
extensions/ql-vscode/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -76,6 +76,48 @@
|
||||
"editor.wordBasedSuggestions": false
|
||||
}
|
||||
},
|
||||
"debuggers": [
|
||||
{
|
||||
"type": "codeql",
|
||||
"label": "CodeQL Debugger",
|
||||
"languages": [
|
||||
"ql"
|
||||
],
|
||||
"configurationAttributes": {
|
||||
"launch": {
|
||||
"properties": {
|
||||
"query": {
|
||||
"type": "string",
|
||||
"description": "Path to query file (.ql)",
|
||||
"default": "${file}"
|
||||
},
|
||||
"database": {
|
||||
"type": "string",
|
||||
"description": "Path to the target database"
|
||||
},
|
||||
"additionalPacks": {
|
||||
"type": [
|
||||
"array",
|
||||
"string"
|
||||
],
|
||||
"description": "Additional folders to search for library packs. Defaults to searching all workspace folders."
|
||||
},
|
||||
"extensionPacks": {
|
||||
"type": [
|
||||
"array",
|
||||
"string"
|
||||
],
|
||||
"description": "Names of extension packs to include in the evaluation. These are resolved from the locations specified in `additionalPacks`."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"variables": {
|
||||
"currentDatabase": "codeQL.getCurrentDatabase",
|
||||
"currentQuery": "codeQL.getCurrentQuery"
|
||||
}
|
||||
}
|
||||
],
|
||||
"jsonValidation": [
|
||||
{
|
||||
"fileMatch": "GitHub.vscode-codeql/databases.json",
|
||||
@@ -293,6 +335,11 @@
|
||||
"scope": "window",
|
||||
"minimum": 0,
|
||||
"description": "Report a warning for any join order whose metric exceeds this value."
|
||||
},
|
||||
"codeQL.databaseDownload.allowHttp": {
|
||||
"type": "boolean",
|
||||
"default": false,
|
||||
"description": "Allow database to be downloaded via HTTP. Warning: enabling this option will allow downloading from insecure servers."
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -309,6 +356,30 @@
|
||||
"command": "codeQL.runQueryContextEditor",
|
||||
"title": "CodeQL: Run Query on Selected Database"
|
||||
},
|
||||
{
|
||||
"command": "codeQL.debugQuery",
|
||||
"title": "CodeQL: Debug Query"
|
||||
},
|
||||
{
|
||||
"command": "codeQL.debugQueryContextEditor",
|
||||
"title": "CodeQL: Debug Query"
|
||||
},
|
||||
{
|
||||
"command": "codeQL.startDebuggingSelection",
|
||||
"title": "CodeQL: Debug Selection"
|
||||
},
|
||||
{
|
||||
"command": "codeQL.startDebuggingSelectionContextEditor",
|
||||
"title": "CodeQL: Debug Selection"
|
||||
},
|
||||
{
|
||||
"command": "codeQL.continueDebuggingSelection",
|
||||
"title": "CodeQL: Debug Selection"
|
||||
},
|
||||
{
|
||||
"command": "codeQL.continueDebuggingSelectionContextEditor",
|
||||
"title": "CodeQL: Debug Selection"
|
||||
},
|
||||
{
|
||||
"command": "codeQL.runQueryOnMultipleDatabases",
|
||||
"title": "CodeQL: Run Query on Multiple Databases"
|
||||
@@ -448,6 +519,14 @@
|
||||
"command": "codeQL.setCurrentDatabase",
|
||||
"title": "CodeQL: Set Current Database"
|
||||
},
|
||||
{
|
||||
"command": "codeQL.getCurrentDatabase",
|
||||
"title": "CodeQL: Get Current Database"
|
||||
},
|
||||
{
|
||||
"command": "codeQL.getCurrentQuery",
|
||||
"title": "CodeQL: Get Current Query"
|
||||
},
|
||||
{
|
||||
"command": "codeQL.viewAst",
|
||||
"title": "CodeQL: View AST"
|
||||
@@ -682,6 +761,10 @@
|
||||
"command": "codeQLTests.acceptOutput",
|
||||
"title": "Accept Test Output"
|
||||
},
|
||||
{
|
||||
"command": "codeQLTests.acceptOutputContextTestItem",
|
||||
"title": "Accept Test Output"
|
||||
},
|
||||
{
|
||||
"command": "codeQLAstViewer.gotoCode",
|
||||
"title": "Go To Code"
|
||||
@@ -977,6 +1060,13 @@
|
||||
"when": "viewItem == testWithSource"
|
||||
}
|
||||
],
|
||||
"testing/item/context": [
|
||||
{
|
||||
"command": "codeQLTests.acceptOutputContextTestItem",
|
||||
"group": "qltest@1",
|
||||
"when": "controllerId == codeql && testId =~ /^test /"
|
||||
}
|
||||
],
|
||||
"explorer/context": [
|
||||
{
|
||||
"command": "codeQL.setCurrentDatabase",
|
||||
@@ -1022,6 +1112,30 @@
|
||||
"command": "codeQL.runQueryContextEditor",
|
||||
"when": "false"
|
||||
},
|
||||
{
|
||||
"command": "codeQL.debugQuery",
|
||||
"when": "config.codeQL.canary && editorLangId == ql && resourceExtname == .ql && !inDebugMode"
|
||||
},
|
||||
{
|
||||
"command": "codeQL.debugQueryContextEditor",
|
||||
"when": "false"
|
||||
},
|
||||
{
|
||||
"command": "codeQL.startDebuggingSelection",
|
||||
"when": "config.codeQL.canary && editorLangId == ql && debugState == inactive && debugConfigurationType == codeql"
|
||||
},
|
||||
{
|
||||
"command": "codeQL.startDebuggingSelectionContextEditor",
|
||||
"when": "false"
|
||||
},
|
||||
{
|
||||
"command": "codeQL.continueDebuggingSelection",
|
||||
"when": "config.codeQL.canary && editorLangId == ql && debugState == stopped && debugType == codeql"
|
||||
},
|
||||
{
|
||||
"command": "codeQL.continueDebuggingSelectionContextEditor",
|
||||
"when": "false"
|
||||
},
|
||||
{
|
||||
"command": "codeQL.runQueryOnMultipleDatabases",
|
||||
"when": "resourceLangId == ql && resourceExtname == .ql"
|
||||
@@ -1070,6 +1184,14 @@
|
||||
"command": "codeQL.setCurrentDatabase",
|
||||
"when": "false"
|
||||
},
|
||||
{
|
||||
"command": "codeQL.getCurrentDatabase",
|
||||
"when": "false"
|
||||
},
|
||||
{
|
||||
"command": "codeQL.getCurrentQuery",
|
||||
"when": "false"
|
||||
},
|
||||
{
|
||||
"command": "codeQL.viewAst",
|
||||
"when": "resourceScheme == codeql-zip-archive"
|
||||
@@ -1325,12 +1447,16 @@
|
||||
{
|
||||
"command": "codeQL.createQuery",
|
||||
"when": "config.codeQL.canary"
|
||||
},
|
||||
{
|
||||
"command": "codeQLTests.acceptOutputContextTestItem",
|
||||
"when": "false"
|
||||
}
|
||||
],
|
||||
"editor/context": [
|
||||
{
|
||||
"command": "codeQL.runQueryContextEditor",
|
||||
"when": "editorLangId == ql && resourceExtname == .ql"
|
||||
"when": "editorLangId == ql && resourceExtname == .ql && !inDebugMode"
|
||||
},
|
||||
{
|
||||
"command": "codeQL.runQueryOnMultipleDatabasesContextEditor",
|
||||
@@ -1350,7 +1476,19 @@
|
||||
},
|
||||
{
|
||||
"command": "codeQL.quickEvalContextEditor",
|
||||
"when": "editorLangId == ql"
|
||||
"when": "editorLangId == ql && debugState == inactive"
|
||||
},
|
||||
{
|
||||
"command": "codeQL.debugQueryContextEditor",
|
||||
"when": "config.codeQL.canary && editorLangId == ql && resourceExtname == .ql && !inDebugMode"
|
||||
},
|
||||
{
|
||||
"command": "codeQL.startDebuggingSelectionContextEditor",
|
||||
"when": "config.codeQL.canary && editorLangId == ql && debugState == inactive && debugConfigurationType == codeql"
|
||||
},
|
||||
{
|
||||
"command": "codeQL.continueDebuggingSelectionContextEditor",
|
||||
"when": "config.codeQL.canary && editorLangId == ql && debugState == stopped && debugType == codeql"
|
||||
},
|
||||
{
|
||||
"command": "codeQL.openReferencedFileContextEditor",
|
||||
@@ -1451,6 +1589,8 @@
|
||||
"@octokit/plugin-retry": "^3.0.9",
|
||||
"@octokit/rest": "^19.0.4",
|
||||
"@vscode/codicons": "^0.0.31",
|
||||
"@vscode/debugadapter": "^1.59.0",
|
||||
"@vscode/debugprotocol": "^1.59.0",
|
||||
"@vscode/webview-ui-toolkit": "^1.0.1",
|
||||
"ajv": "^8.11.0",
|
||||
"child-process-promise": "^2.2.1",
|
||||
@@ -1498,7 +1638,7 @@
|
||||
"@storybook/addon-essentials": "^6.5.17-alpha.0",
|
||||
"@storybook/addon-interactions": "^6.5.17-alpha.0",
|
||||
"@storybook/addon-links": "^6.5.17-alpha.0",
|
||||
"@storybook/builder-webpack5": "^7.0.4",
|
||||
"@storybook/builder-webpack5": "^6.5.17-alpha.0",
|
||||
"@storybook/manager-webpack5": "^6.5.17-alpha.0",
|
||||
"@storybook/react": "^6.5.17-alpha.0",
|
||||
"@storybook/testing-library": "^0.0.13",
|
||||
|
||||
@@ -23,7 +23,7 @@ import {
|
||||
getErrorStack,
|
||||
} from "./pure/helpers-pure";
|
||||
import { QueryMetadata, SortDirection } from "./pure/interface-types";
|
||||
import { Logger, ProgressReporter } from "./common";
|
||||
import { BaseLogger, Logger, ProgressReporter } from "./common";
|
||||
import { CompilationMessage } from "./pure/legacy-messages";
|
||||
import { sarifParser } from "./sarif-parser";
|
||||
import { walkDirectory } from "./helpers";
|
||||
@@ -149,6 +149,7 @@ export interface TestCompleted {
|
||||
compilationMs: number;
|
||||
evaluationMs: number;
|
||||
expected: string;
|
||||
actual?: string;
|
||||
diff: string[] | undefined;
|
||||
failureDescription?: string;
|
||||
failureStage?: string;
|
||||
@@ -439,7 +440,7 @@ export class CodeQLCliServer implements Disposable {
|
||||
command: string[],
|
||||
commandArgs: string[],
|
||||
cancellationToken?: CancellationToken,
|
||||
logger?: Logger,
|
||||
logger?: BaseLogger,
|
||||
): AsyncGenerator<string, void, unknown> {
|
||||
// Add format argument first, in case commandArgs contains positional parameters.
|
||||
const args = [...command, "--format", "jsonz", ...commandArgs];
|
||||
@@ -447,6 +448,11 @@ export class CodeQLCliServer implements Disposable {
|
||||
// Spawn the CodeQL process
|
||||
const codeqlPath = await this.getCodeQlPath();
|
||||
const childPromise = spawn(codeqlPath, args);
|
||||
// Avoid a runtime message about unhandled rejection.
|
||||
childPromise.catch(() => {
|
||||
/**/
|
||||
});
|
||||
|
||||
const child = childPromise.childProcess;
|
||||
|
||||
let cancellationRegistration: Disposable | undefined = undefined;
|
||||
@@ -497,7 +503,7 @@ export class CodeQLCliServer implements Disposable {
|
||||
logger,
|
||||
}: {
|
||||
cancellationToken?: CancellationToken;
|
||||
logger?: Logger;
|
||||
logger?: BaseLogger;
|
||||
} = {},
|
||||
): AsyncGenerator<EventType, void, unknown> {
|
||||
for await (const event of this.runAsyncCodeQlCliCommandInternal(
|
||||
@@ -776,7 +782,7 @@ export class CodeQLCliServer implements Disposable {
|
||||
logger,
|
||||
}: {
|
||||
cancellationToken?: CancellationToken;
|
||||
logger?: Logger;
|
||||
logger?: BaseLogger;
|
||||
},
|
||||
): AsyncGenerator<TestCompleted, void, unknown> {
|
||||
const subcommandArgs = this.cliConfig.additionalTestArguments.concat([
|
||||
@@ -1661,7 +1667,7 @@ const lineEndings = ["\r\n", "\r", "\n"];
|
||||
* @param stream The stream to log.
|
||||
* @param logger The logger that will consume the stream output.
|
||||
*/
|
||||
async function logStream(stream: Readable, logger: Logger): Promise<void> {
|
||||
async function logStream(stream: Readable, logger: BaseLogger): Promise<void> {
|
||||
for await (const line of splitStreamAtSeparators(stream, lineEndings)) {
|
||||
// Await the result of log here in order to ensure the logs are written in the correct order.
|
||||
await logger.log(line);
|
||||
|
||||
@@ -11,6 +11,7 @@ import type {
|
||||
VariantAnalysisScannedRepository,
|
||||
VariantAnalysisScannedRepositoryResult,
|
||||
} from "../variant-analysis/shared/variant-analysis";
|
||||
import type { QLDebugConfiguration } from "../debugger/debug-configuration";
|
||||
|
||||
// A command function matching the signature that VS Code calls when
|
||||
// a command is invoked from the title bar of a TreeView with
|
||||
@@ -88,6 +89,15 @@ export type BuiltInVsCodeCommands = {
|
||||
"vscode.open": (uri: Uri) => Promise<void>;
|
||||
"vscode.openFolder": (uri: Uri) => Promise<void>;
|
||||
revealInExplorer: (uri: Uri) => Promise<void>;
|
||||
// We type the `config` property specifically as a CodeQL debug configuration, since that's the
|
||||
// only kinds we specify anyway.
|
||||
"workbench.action.debug.start": (options?: {
|
||||
config?: Partial<QLDebugConfiguration>;
|
||||
noDebug?: boolean;
|
||||
}) => Promise<void>;
|
||||
"workbench.action.debug.stepInto": () => Promise<void>;
|
||||
"workbench.action.debug.stepOver": () => Promise<void>;
|
||||
"workbench.action.debug.stepOut": () => Promise<void>;
|
||||
};
|
||||
|
||||
// Commands that are available before the extension is fully activated.
|
||||
@@ -135,9 +145,20 @@ export type LocalQueryCommands = {
|
||||
"codeQL.quickEvalContextEditor": (uri: Uri) => Promise<void>;
|
||||
"codeQL.codeLensQuickEval": (uri: Uri, range: Range) => Promise<void>;
|
||||
"codeQL.quickQuery": () => Promise<void>;
|
||||
"codeQL.getCurrentQuery": () => Promise<string>;
|
||||
"codeQL.createQuery": () => Promise<void>;
|
||||
};
|
||||
|
||||
// Debugger commands
|
||||
export type DebuggerCommands = {
|
||||
"codeQL.debugQuery": (uri: Uri | undefined) => Promise<void>;
|
||||
"codeQL.debugQueryContextEditor": (uri: Uri) => Promise<void>;
|
||||
"codeQL.startDebuggingSelection": () => Promise<void>;
|
||||
"codeQL.startDebuggingSelectionContextEditor": () => Promise<void>;
|
||||
"codeQL.continueDebuggingSelection": () => Promise<void>;
|
||||
"codeQL.continueDebuggingSelectionContextEditor": () => Promise<void>;
|
||||
};
|
||||
|
||||
export type ResultsViewCommands = {
|
||||
"codeQLQueryResults.up": () => Promise<void>;
|
||||
"codeQLQueryResults.down": () => Promise<void>;
|
||||
@@ -220,6 +241,7 @@ export type LocalDatabasesCommands = {
|
||||
|
||||
// Internal commands
|
||||
"codeQLDatabases.removeOrphanedDatabases": () => Promise<void>;
|
||||
"codeQL.getCurrentDatabase": () => Promise<string | undefined>;
|
||||
};
|
||||
|
||||
// Commands tied to variant analysis
|
||||
@@ -299,6 +321,9 @@ export type SummaryLanguageSupportCommands = {
|
||||
export type TestUICommands = {
|
||||
"codeQLTests.showOutputDifferences": (node: TestTreeNode) => Promise<void>;
|
||||
"codeQLTests.acceptOutput": (node: TestTreeNode) => Promise<void>;
|
||||
"codeQLTests.acceptOutputContextTestItem": (
|
||||
node: TestTreeNode,
|
||||
) => Promise<void>;
|
||||
};
|
||||
|
||||
export type MockGitHubApiServerCommands = {
|
||||
@@ -315,6 +340,7 @@ export type AllExtensionCommands = BaseCommands &
|
||||
ResultsViewCommands &
|
||||
QueryHistoryCommands &
|
||||
LocalDatabasesCommands &
|
||||
DebuggerCommands &
|
||||
VariantAnalysisCommands &
|
||||
DatabasePanelCommands &
|
||||
AstCfgCommands &
|
||||
|
||||
@@ -608,3 +608,14 @@ export const CODESPACES_TEMPLATE = new Setting(
|
||||
export function isCodespacesTemplate() {
|
||||
return !!CODESPACES_TEMPLATE.getValue<boolean>();
|
||||
}
|
||||
|
||||
const DATABASE_DOWNLOAD_SETTING = new Setting("databaseDownload", ROOT_SETTING);
|
||||
|
||||
export const ALLOW_HTTP_SETTING = new Setting(
|
||||
"allowHttp",
|
||||
DATABASE_DOWNLOAD_SETTING,
|
||||
);
|
||||
|
||||
export function allowHttp(): boolean {
|
||||
return ALLOW_HTTP_SETTING.getValue<boolean>() || false;
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@ import {
|
||||
showAndLogExceptionWithTelemetry,
|
||||
} from "../helpers";
|
||||
import { extLogger } from "../common";
|
||||
import { outputFile, readFile } from "fs-extra";
|
||||
import { outputFile, pathExists, readFile } from "fs-extra";
|
||||
import { load as loadYaml } from "js-yaml";
|
||||
import { DatabaseItem, DatabaseManager } from "../local-databases";
|
||||
import { CodeQLCliServer } from "../cli";
|
||||
@@ -183,6 +183,10 @@ export class DataExtensionsEditorView extends AbstractWebview<
|
||||
|
||||
protected async loadExistingModeledMethods(): Promise<void> {
|
||||
try {
|
||||
if (!(await pathExists(this.modelFile.filename))) {
|
||||
return;
|
||||
}
|
||||
|
||||
const yaml = await readFile(this.modelFile.filename, "utf8");
|
||||
|
||||
const data = loadYaml(yaml, {
|
||||
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
} from "./common/github-url-identifier-helper";
|
||||
import { Credentials } from "./common/authentication";
|
||||
import { AppCommandManager } from "./common/commands";
|
||||
import { ALLOW_HTTP_SETTING } from "./config";
|
||||
|
||||
/**
|
||||
* Prompts a user to fetch a database from a remote location. Database is assumed to be an archive file.
|
||||
@@ -49,7 +50,7 @@ export async function promptImportInternetDatabase(
|
||||
return;
|
||||
}
|
||||
|
||||
validateHttpsUrl(databaseUrl);
|
||||
validateUrl(databaseUrl);
|
||||
|
||||
const item = await databaseArchiveFetcher(
|
||||
databaseUrl,
|
||||
@@ -356,7 +357,7 @@ async function getStorageFolder(storagePath: string, urlStr: string) {
|
||||
return folderName;
|
||||
}
|
||||
|
||||
function validateHttpsUrl(databaseUrl: string) {
|
||||
function validateUrl(databaseUrl: string) {
|
||||
let uri;
|
||||
try {
|
||||
uri = Uri.parse(databaseUrl, true);
|
||||
@@ -364,7 +365,7 @@ function validateHttpsUrl(databaseUrl: string) {
|
||||
throw new Error(`Invalid url: ${databaseUrl}`);
|
||||
}
|
||||
|
||||
if (uri.scheme !== "https") {
|
||||
if (!ALLOW_HTTP_SETTING.getValue() && uri.scheme !== "https") {
|
||||
throw new Error("Must use https for downloading a database.");
|
||||
}
|
||||
}
|
||||
|
||||
132
extensions/ql-vscode/src/debugger/debug-configuration.ts
Normal file
132
extensions/ql-vscode/src/debugger/debug-configuration.ts
Normal file
@@ -0,0 +1,132 @@
|
||||
import {
|
||||
CancellationToken,
|
||||
DebugConfiguration,
|
||||
DebugConfigurationProvider,
|
||||
WorkspaceFolder,
|
||||
} from "vscode";
|
||||
import { getOnDiskWorkspaceFolders, showAndLogErrorMessage } from "../helpers";
|
||||
import { LocalQueries } from "../local-queries";
|
||||
import { getQuickEvalContext, validateQueryPath } from "../run-queries-shared";
|
||||
import * as CodeQLProtocol from "./debug-protocol";
|
||||
import { getErrorMessage } from "../pure/helpers-pure";
|
||||
|
||||
/**
|
||||
* The CodeQL launch arguments, as specified in "launch.json".
|
||||
*/
|
||||
export interface QLDebugArgs {
|
||||
query?: string;
|
||||
database?: string;
|
||||
additionalPacks?: string[] | string;
|
||||
extensionPacks?: string[] | string;
|
||||
quickEval?: boolean;
|
||||
noDebug?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* The debug configuration for a CodeQL configuration.
|
||||
*
|
||||
* This just combines `QLDebugArgs` with the standard debug configuration properties.
|
||||
*/
|
||||
export type QLDebugConfiguration = DebugConfiguration & QLDebugArgs;
|
||||
|
||||
/**
|
||||
* A CodeQL debug configuration after all variables and defaults have been resolved. This is what
|
||||
* is passed to the debug adapter via the `launch` request.
|
||||
*/
|
||||
export type QLResolvedDebugConfiguration = DebugConfiguration &
|
||||
CodeQLProtocol.LaunchConfig;
|
||||
|
||||
/** If the specified value is a single element, then turn it into an array containing that element. */
|
||||
function makeArray<T extends Exclude<any, any[]>>(value: T | T[]): T[] {
|
||||
if (Array.isArray(value)) {
|
||||
return value;
|
||||
} else {
|
||||
return [value];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Implementation of `DebugConfigurationProvider` for CodeQL.
|
||||
*/
|
||||
export class QLDebugConfigurationProvider
|
||||
implements DebugConfigurationProvider
|
||||
{
|
||||
public constructor(private readonly localQueries: LocalQueries) {}
|
||||
|
||||
public resolveDebugConfiguration(
|
||||
_folder: WorkspaceFolder | undefined,
|
||||
debugConfiguration: DebugConfiguration,
|
||||
_token?: CancellationToken,
|
||||
): DebugConfiguration {
|
||||
const qlConfiguration = <QLDebugConfiguration>debugConfiguration;
|
||||
|
||||
// Fill in defaults for properties whose default value is a command invocation. VS Code will
|
||||
// invoke any commands to fill in actual values, then call
|
||||
// `resolveDebugConfigurationWithSubstitutedVariables()`with the result.
|
||||
const resultConfiguration: QLDebugConfiguration = {
|
||||
...qlConfiguration,
|
||||
query: qlConfiguration.query ?? "${command:currentQuery}",
|
||||
database: qlConfiguration.database ?? "${command:currentDatabase}",
|
||||
};
|
||||
|
||||
return resultConfiguration;
|
||||
}
|
||||
|
||||
public async resolveDebugConfigurationWithSubstitutedVariables(
|
||||
_folder: WorkspaceFolder | undefined,
|
||||
debugConfiguration: DebugConfiguration,
|
||||
_token?: CancellationToken,
|
||||
): Promise<DebugConfiguration | null> {
|
||||
try {
|
||||
const qlConfiguration = debugConfiguration as QLDebugConfiguration;
|
||||
if (qlConfiguration.query === undefined) {
|
||||
throw new Error("No query was specified in the debug configuration.");
|
||||
}
|
||||
if (qlConfiguration.database === undefined) {
|
||||
throw new Error(
|
||||
"No database was specified in the debug configuration.",
|
||||
);
|
||||
}
|
||||
|
||||
// Fill in defaults here, instead of in `resolveDebugConfiguration`, to avoid the highly
|
||||
// unusual case where one of the computed default values looks like a variable substitution.
|
||||
const additionalPacks = makeArray(
|
||||
qlConfiguration.additionalPacks ?? getOnDiskWorkspaceFolders(),
|
||||
);
|
||||
|
||||
// Default to computing the extension packs based on the extension configuration and the search
|
||||
// path.
|
||||
const extensionPacks = makeArray(
|
||||
qlConfiguration.extensionPacks ??
|
||||
(await this.localQueries.getDefaultExtensionPacks(additionalPacks)),
|
||||
);
|
||||
|
||||
const quickEval = qlConfiguration.quickEval ?? false;
|
||||
validateQueryPath(qlConfiguration.query, quickEval);
|
||||
|
||||
const quickEvalContext = quickEval
|
||||
? await getQuickEvalContext(undefined)
|
||||
: undefined;
|
||||
|
||||
const resultConfiguration: QLResolvedDebugConfiguration = {
|
||||
name: qlConfiguration.name,
|
||||
request: qlConfiguration.request,
|
||||
type: qlConfiguration.type,
|
||||
query: qlConfiguration.query,
|
||||
database: qlConfiguration.database,
|
||||
additionalPacks,
|
||||
extensionPacks,
|
||||
quickEvalContext,
|
||||
noDebug: qlConfiguration.noDebug ?? false,
|
||||
};
|
||||
|
||||
return resultConfiguration;
|
||||
} catch (e) {
|
||||
// Any unhandled exception will result in an OS-native error message box, which seems ugly.
|
||||
// We'll just show a real VS Code error message, then return null to prevent the debug session
|
||||
// from starting.
|
||||
void showAndLogErrorMessage(getErrorMessage(e));
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
102
extensions/ql-vscode/src/debugger/debug-protocol.ts
Normal file
102
extensions/ql-vscode/src/debugger/debug-protocol.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
import { DebugProtocol } from "@vscode/debugprotocol";
|
||||
import { QueryResultType } from "../pure/new-messages";
|
||||
import { QuickEvalContext } from "../run-queries-shared";
|
||||
|
||||
// Events
|
||||
|
||||
export type Event = { type: "event" };
|
||||
|
||||
export type StoppedEvent = DebugProtocol.StoppedEvent &
|
||||
Event & { event: "stopped" };
|
||||
|
||||
export type InitializedEvent = DebugProtocol.InitializedEvent &
|
||||
Event & { event: "initialized" };
|
||||
|
||||
export type ExitedEvent = DebugProtocol.ExitedEvent &
|
||||
Event & { event: "exited" };
|
||||
|
||||
export type OutputEvent = DebugProtocol.OutputEvent &
|
||||
Event & { event: "output" };
|
||||
|
||||
/**
|
||||
* Custom event to provide additional information about a running evaluation.
|
||||
*/
|
||||
export interface EvaluationStartedEvent extends Event {
|
||||
event: "codeql-evaluation-started";
|
||||
body: {
|
||||
id: string;
|
||||
outputDir: string;
|
||||
quickEvalContext: QuickEvalContext | undefined;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Custom event to provide additional information about a completed evaluation.
|
||||
*/
|
||||
export interface EvaluationCompletedEvent extends Event {
|
||||
event: "codeql-evaluation-completed";
|
||||
body: {
|
||||
resultType: QueryResultType;
|
||||
message: string | undefined;
|
||||
evaluationTime: number;
|
||||
};
|
||||
}
|
||||
|
||||
export type AnyEvent =
|
||||
| StoppedEvent
|
||||
| ExitedEvent
|
||||
| InitializedEvent
|
||||
| OutputEvent
|
||||
| EvaluationStartedEvent
|
||||
| EvaluationCompletedEvent;
|
||||
|
||||
// Requests
|
||||
|
||||
export type Request = DebugProtocol.Request & { type: "request" };
|
||||
|
||||
export type InitializeRequest = DebugProtocol.InitializeRequest &
|
||||
Request & { command: "initialize" };
|
||||
|
||||
export interface LaunchConfig {
|
||||
/** Full path to query (.ql) file. */
|
||||
query: string;
|
||||
/** Full path to the database directory. */
|
||||
database: string;
|
||||
/** Full paths to `--additional-packs` directories. */
|
||||
additionalPacks: string[];
|
||||
/** Pack names of extension packs. */
|
||||
extensionPacks: string[];
|
||||
/** Optional quick evaluation context. */
|
||||
quickEvalContext: QuickEvalContext | undefined;
|
||||
/** Run the query without debugging it. */
|
||||
noDebug: boolean;
|
||||
}
|
||||
|
||||
export interface LaunchRequest extends Request, DebugProtocol.LaunchRequest {
|
||||
type: "request";
|
||||
command: "launch";
|
||||
arguments: DebugProtocol.LaunchRequestArguments & LaunchConfig;
|
||||
}
|
||||
|
||||
export interface QuickEvalRequest extends Request {
|
||||
command: "codeql-quickeval";
|
||||
arguments: {
|
||||
quickEvalContext: QuickEvalContext;
|
||||
};
|
||||
}
|
||||
|
||||
export type AnyRequest = InitializeRequest | LaunchRequest | QuickEvalRequest;
|
||||
|
||||
// Responses
|
||||
|
||||
export type Response = DebugProtocol.Response & { type: "response" };
|
||||
|
||||
export type InitializeResponse = DebugProtocol.InitializeResponse &
|
||||
Response & { command: "initialize" };
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-interface
|
||||
export interface QuickEvalResponse extends Response {}
|
||||
|
||||
export type AnyResponse = InitializeResponse | QuickEvalResponse;
|
||||
|
||||
export type AnyProtocolMessage = AnyEvent | AnyRequest | AnyResponse;
|
||||
617
extensions/ql-vscode/src/debugger/debug-session.ts
Normal file
617
extensions/ql-vscode/src/debugger/debug-session.ts
Normal file
@@ -0,0 +1,617 @@
|
||||
import {
|
||||
ContinuedEvent,
|
||||
Event,
|
||||
ExitedEvent,
|
||||
InitializedEvent,
|
||||
LoggingDebugSession,
|
||||
OutputEvent,
|
||||
ProgressEndEvent,
|
||||
StoppedEvent,
|
||||
TerminatedEvent,
|
||||
} from "@vscode/debugadapter";
|
||||
import { DebugProtocol as Protocol } from "@vscode/debugprotocol";
|
||||
import { Disposable } from "vscode";
|
||||
import { CancellationTokenSource } from "vscode-jsonrpc";
|
||||
import { BaseLogger, LogOptions, queryServerLogger } from "../common";
|
||||
import { QueryResultType } from "../pure/new-messages";
|
||||
import { CoreQueryResults, CoreQueryRun, QueryRunner } from "../queryRunner";
|
||||
import * as CodeQLProtocol from "./debug-protocol";
|
||||
import { QuickEvalContext } from "../run-queries-shared";
|
||||
import { getErrorMessage } from "../pure/helpers-pure";
|
||||
import { DisposableObject } from "../pure/disposable-object";
|
||||
|
||||
// More complete implementations of `Event` for certain events, because the classes from
|
||||
// `@vscode/debugadapter` make it more difficult to provide some of the message values.
|
||||
|
||||
class ProgressStartEvent extends Event implements Protocol.ProgressStartEvent {
|
||||
public readonly event = "progressStart";
|
||||
public readonly body: {
|
||||
progressId: string;
|
||||
title: string;
|
||||
requestId?: number;
|
||||
cancellable?: boolean;
|
||||
message?: string;
|
||||
percentage?: number;
|
||||
};
|
||||
|
||||
constructor(
|
||||
progressId: string,
|
||||
title: string,
|
||||
message?: string,
|
||||
percentage?: number,
|
||||
) {
|
||||
super("progressStart");
|
||||
this.body = {
|
||||
progressId,
|
||||
title,
|
||||
message,
|
||||
percentage,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
class ProgressUpdateEvent
|
||||
extends Event
|
||||
implements Protocol.ProgressUpdateEvent
|
||||
{
|
||||
public readonly event = "progressUpdate";
|
||||
public readonly body: {
|
||||
progressId: string;
|
||||
message?: string;
|
||||
percentage?: number;
|
||||
};
|
||||
|
||||
constructor(progressId: string, message?: string, percentage?: number) {
|
||||
super("progressUpdate");
|
||||
this.body = {
|
||||
progressId,
|
||||
message,
|
||||
percentage,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
class EvaluationStartedEvent
|
||||
extends Event
|
||||
implements CodeQLProtocol.EvaluationStartedEvent
|
||||
{
|
||||
public readonly type = "event";
|
||||
public readonly event = "codeql-evaluation-started";
|
||||
public readonly body: CodeQLProtocol.EvaluationStartedEvent["body"];
|
||||
|
||||
constructor(
|
||||
id: string,
|
||||
outputDir: string,
|
||||
quickEvalContext: QuickEvalContext | undefined,
|
||||
) {
|
||||
super("codeql-evaluation-started");
|
||||
this.body = {
|
||||
id,
|
||||
outputDir,
|
||||
quickEvalContext,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
class EvaluationCompletedEvent
|
||||
extends Event
|
||||
implements CodeQLProtocol.EvaluationCompletedEvent
|
||||
{
|
||||
public readonly type = "event";
|
||||
public readonly event = "codeql-evaluation-completed";
|
||||
public readonly body: CodeQLProtocol.EvaluationCompletedEvent["body"];
|
||||
|
||||
constructor(results: CoreQueryResults) {
|
||||
super("codeql-evaluation-completed");
|
||||
this.body = results;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Possible states of the debug session. Used primarily to guard against unexpected requests.
|
||||
*/
|
||||
type State =
|
||||
| "uninitialized"
|
||||
| "initialized"
|
||||
| "running"
|
||||
| "stopped"
|
||||
| "terminated";
|
||||
|
||||
// IDs for error messages generated by the debug adapter itself.
|
||||
|
||||
/** Received a DAP message while in an unexpected state. */
|
||||
const ERROR_UNEXPECTED_STATE = 1;
|
||||
|
||||
/** ID of the "thread" that represents the query evaluation. */
|
||||
const QUERY_THREAD_ID = 1;
|
||||
|
||||
/** The user-visible name of the query evaluation thread. */
|
||||
const QUERY_THREAD_NAME = "Evaluation thread";
|
||||
|
||||
/**
|
||||
* An active query evaluation within a debug session.
|
||||
*
|
||||
* This class encapsulates the state and resources associated with the running query, to avoid
|
||||
* having multiple properties within `QLDebugSession` that are only defined during query evaluation.
|
||||
*/
|
||||
class RunningQuery extends DisposableObject {
|
||||
private readonly tokenSource = this.push(new CancellationTokenSource());
|
||||
public readonly queryRun: CoreQueryRun;
|
||||
|
||||
public constructor(
|
||||
queryRunner: QueryRunner,
|
||||
config: CodeQLProtocol.LaunchConfig,
|
||||
private readonly quickEvalContext: QuickEvalContext | undefined,
|
||||
queryStorageDir: string,
|
||||
private readonly logger: BaseLogger,
|
||||
private readonly sendEvent: (event: Event) => void,
|
||||
) {
|
||||
super();
|
||||
|
||||
// Create the query run, which will give us some information about the query even before the
|
||||
// evaluation has completed.
|
||||
this.queryRun = queryRunner.createQueryRun(
|
||||
config.database,
|
||||
{
|
||||
queryPath: config.query,
|
||||
quickEvalPosition: quickEvalContext?.quickEvalPosition,
|
||||
},
|
||||
true,
|
||||
config.additionalPacks,
|
||||
config.extensionPacks,
|
||||
queryStorageDir,
|
||||
undefined,
|
||||
undefined,
|
||||
);
|
||||
}
|
||||
|
||||
public get id(): string {
|
||||
return this.queryRun.id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Evaluates the query, firing progress events along the way. The evaluation can be cancelled by
|
||||
* calling `cancel()`.
|
||||
*
|
||||
* This function does not throw exceptions to report query evaluation failure. It just returns an
|
||||
* evaluation result with a failure message instead.
|
||||
*/
|
||||
public async evaluate(): Promise<
|
||||
CodeQLProtocol.EvaluationCompletedEvent["body"]
|
||||
> {
|
||||
// Send the `EvaluationStarted` event first, to let the client known where the outputs are
|
||||
// going to show up.
|
||||
this.sendEvent(
|
||||
new EvaluationStartedEvent(
|
||||
this.queryRun.id,
|
||||
this.queryRun.outputDir.querySaveDir,
|
||||
this.quickEvalContext,
|
||||
),
|
||||
);
|
||||
|
||||
try {
|
||||
// Report progress via the debugger protocol.
|
||||
const progressStart = new ProgressStartEvent(
|
||||
this.queryRun.id,
|
||||
"Running query",
|
||||
undefined,
|
||||
0,
|
||||
);
|
||||
progressStart.body.cancellable = true;
|
||||
this.sendEvent(progressStart);
|
||||
try {
|
||||
return await this.queryRun.evaluate(
|
||||
(p) => {
|
||||
const progressUpdate = new ProgressUpdateEvent(
|
||||
this.queryRun.id,
|
||||
p.message,
|
||||
(p.step * 100) / p.maxStep,
|
||||
);
|
||||
this.sendEvent(progressUpdate);
|
||||
},
|
||||
this.tokenSource.token,
|
||||
this.logger,
|
||||
);
|
||||
} finally {
|
||||
this.sendEvent(new ProgressEndEvent(this.queryRun.id));
|
||||
}
|
||||
} catch (e) {
|
||||
const message = getErrorMessage(e);
|
||||
return {
|
||||
resultType: QueryResultType.OTHER_ERROR,
|
||||
message,
|
||||
evaluationTime: 0,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempts to cancel the running evaluation.
|
||||
*/
|
||||
public cancel(): void {
|
||||
this.tokenSource.cancel();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* An in-process implementation of the debug adapter for CodeQL queries.
|
||||
*
|
||||
* For now, this is pretty much just a wrapper around the query server.
|
||||
*/
|
||||
export class QLDebugSession extends LoggingDebugSession implements Disposable {
|
||||
/** A `BaseLogger` that sends output to the debug console. */
|
||||
private readonly logger: BaseLogger = {
|
||||
log: async (message: string, _options: LogOptions): Promise<void> => {
|
||||
// Only send the output event if we're still connected to the query evaluation.
|
||||
if (this.runningQuery !== undefined) {
|
||||
this.sendEvent(new OutputEvent(message, "console"));
|
||||
}
|
||||
},
|
||||
};
|
||||
private state: State = "uninitialized";
|
||||
private terminateOnComplete = false;
|
||||
private args: CodeQLProtocol.LaunchRequest["arguments"] | undefined =
|
||||
undefined;
|
||||
private runningQuery: RunningQuery | undefined = undefined;
|
||||
private lastResultType: QueryResultType = QueryResultType.CANCELLATION;
|
||||
|
||||
constructor(
|
||||
private readonly queryStorageDir: string,
|
||||
private readonly queryRunner: QueryRunner,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
public dispose(): void {
|
||||
if (this.runningQuery !== undefined) {
|
||||
this.runningQuery.cancel();
|
||||
}
|
||||
}
|
||||
|
||||
protected dispatchRequest(request: Protocol.Request): void {
|
||||
// We just defer to the base class implementation, but having this override makes it easy to set
|
||||
// a breakpoint that will be hit for any message received by the debug adapter.
|
||||
void queryServerLogger.log(`DAP request: ${request.command}`);
|
||||
super.dispatchRequest(request);
|
||||
}
|
||||
|
||||
private unexpectedState(response: Protocol.Response): void {
|
||||
this.sendErrorResponse(
|
||||
response,
|
||||
ERROR_UNEXPECTED_STATE,
|
||||
"CodeQL debug adapter received request '{_request}' while in unexpected state '{_actualState}'.",
|
||||
{
|
||||
_request: response.command,
|
||||
_actualState: this.state,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
protected initializeRequest(
|
||||
response: Protocol.InitializeResponse,
|
||||
_args: Protocol.InitializeRequestArguments,
|
||||
): void {
|
||||
switch (this.state) {
|
||||
case "uninitialized":
|
||||
response.body = response.body ?? {};
|
||||
response.body.supportsStepBack = false;
|
||||
response.body.supportsStepInTargetsRequest = false;
|
||||
response.body.supportsRestartFrame = false;
|
||||
response.body.supportsGotoTargetsRequest = false;
|
||||
response.body.supportsCancelRequest = true;
|
||||
response.body.supportsTerminateRequest = true;
|
||||
response.body.supportsModulesRequest = false;
|
||||
response.body.supportsConfigurationDoneRequest = true;
|
||||
response.body.supportsRestartRequest = false;
|
||||
this.state = "initialized";
|
||||
this.sendResponse(response);
|
||||
|
||||
this.sendEvent(new InitializedEvent());
|
||||
break;
|
||||
|
||||
default:
|
||||
this.unexpectedState(response);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
protected disconnectRequest(
|
||||
response: Protocol.DisconnectResponse,
|
||||
_args: Protocol.DisconnectArguments,
|
||||
_request?: Protocol.Request,
|
||||
): void {
|
||||
// The client is forcing a disconnect. We'll signal cancellation, but since this request means
|
||||
// that the debug session itself is about to go away, we'll stop processing events from the
|
||||
// evaluation to avoid sending them to the client that is no longer interested in them.
|
||||
this.terminateOrDisconnect(response, true);
|
||||
}
|
||||
|
||||
protected terminateRequest(
|
||||
response: Protocol.TerminateResponse,
|
||||
_args: Protocol.TerminateArguments,
|
||||
_request?: Protocol.Request,
|
||||
): void {
|
||||
// The client is requesting a graceful termination. This will signal the cancellation token of
|
||||
// any in-progress evaluation, but that evaluation will continue to report events (like
|
||||
// progress) until the cancellation takes effect.
|
||||
this.terminateOrDisconnect(response, false);
|
||||
}
|
||||
|
||||
private terminateOrDisconnect(
|
||||
response: Protocol.Response,
|
||||
force: boolean,
|
||||
): void {
|
||||
const runningQuery = this.runningQuery;
|
||||
if (force) {
|
||||
// Disconnect from the running query so that we stop processing its progress events.
|
||||
this.runningQuery = undefined;
|
||||
}
|
||||
if (runningQuery !== undefined) {
|
||||
this.terminateOnComplete = true;
|
||||
runningQuery.cancel();
|
||||
} else if (this.state === "stopped") {
|
||||
this.terminateAndExit();
|
||||
}
|
||||
|
||||
this.sendResponse(response);
|
||||
}
|
||||
|
||||
protected launchRequest(
|
||||
response: Protocol.LaunchResponse,
|
||||
args: CodeQLProtocol.LaunchRequest["arguments"],
|
||||
_request?: Protocol.Request,
|
||||
): void {
|
||||
switch (this.state) {
|
||||
case "initialized":
|
||||
this.args = args;
|
||||
|
||||
// If `noDebug` is set, then terminate after evaluation instead of stopping.
|
||||
this.terminateOnComplete = this.args.noDebug === true;
|
||||
|
||||
response.body = response.body ?? {};
|
||||
|
||||
// Send the response immediately. We'll send a "stopped" message when the evaluation is complete.
|
||||
this.sendResponse(response);
|
||||
|
||||
void this.evaluate(this.args.quickEvalContext);
|
||||
break;
|
||||
|
||||
default:
|
||||
this.unexpectedState(response);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
protected nextRequest(
|
||||
response: Protocol.NextResponse,
|
||||
_args: Protocol.NextArguments,
|
||||
_request?: Protocol.Request,
|
||||
): void {
|
||||
this.stepRequest(response);
|
||||
}
|
||||
|
||||
protected stepInRequest(
|
||||
response: Protocol.StepInResponse,
|
||||
_args: Protocol.StepInArguments,
|
||||
_request?: Protocol.Request,
|
||||
): void {
|
||||
this.stepRequest(response);
|
||||
}
|
||||
|
||||
protected stepOutRequest(
|
||||
response: Protocol.Response,
|
||||
_args: Protocol.StepOutArguments,
|
||||
_request?: Protocol.Request,
|
||||
): void {
|
||||
this.stepRequest(response);
|
||||
}
|
||||
|
||||
protected stepBackRequest(
|
||||
response: Protocol.StepBackResponse,
|
||||
_args: Protocol.StepBackArguments,
|
||||
_request?: Protocol.Request,
|
||||
): void {
|
||||
this.stepRequest(response);
|
||||
}
|
||||
|
||||
private stepRequest(response: Protocol.Response): void {
|
||||
switch (this.state) {
|
||||
case "stopped":
|
||||
this.sendResponse(response);
|
||||
// We don't do anything with stepping yet, so just announce that we've stopped without
|
||||
// actually doing anything.
|
||||
// We don't even send the `EvaluationCompletedEvent`.
|
||||
this.reportStopped();
|
||||
break;
|
||||
|
||||
default:
|
||||
this.unexpectedState(response);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
protected continueRequest(
|
||||
response: Protocol.ContinueResponse,
|
||||
_args: Protocol.ContinueArguments,
|
||||
_request?: Protocol.Request,
|
||||
): void {
|
||||
switch (this.state) {
|
||||
case "stopped":
|
||||
response.body = response.body ?? {};
|
||||
response.body.allThreadsContinued = true;
|
||||
|
||||
// Send the response immediately. We'll send a "stopped" message when the evaluation is complete.
|
||||
this.sendResponse(response);
|
||||
|
||||
void this.evaluate(undefined);
|
||||
break;
|
||||
|
||||
default:
|
||||
this.unexpectedState(response);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
protected cancelRequest(
|
||||
response: Protocol.CancelResponse,
|
||||
args: Protocol.CancelArguments,
|
||||
_request?: Protocol.Request,
|
||||
): void {
|
||||
if (
|
||||
args.progressId !== undefined &&
|
||||
this.runningQuery?.id === args.progressId
|
||||
) {
|
||||
this.runningQuery.cancel();
|
||||
}
|
||||
|
||||
this.sendResponse(response);
|
||||
}
|
||||
|
||||
protected threadsRequest(
|
||||
response: Protocol.ThreadsResponse,
|
||||
_request?: Protocol.Request,
|
||||
): void {
|
||||
response.body = response.body ?? {};
|
||||
response.body.threads = [
|
||||
{
|
||||
id: QUERY_THREAD_ID,
|
||||
name: QUERY_THREAD_NAME,
|
||||
},
|
||||
];
|
||||
|
||||
this.sendResponse(response);
|
||||
}
|
||||
|
||||
protected stackTraceRequest(
|
||||
response: Protocol.StackTraceResponse,
|
||||
_args: Protocol.StackTraceArguments,
|
||||
_request?: Protocol.Request,
|
||||
): void {
|
||||
response.body = response.body ?? {};
|
||||
response.body.stackFrames = []; // No frames for now.
|
||||
|
||||
super.stackTraceRequest(response, _args, _request);
|
||||
}
|
||||
|
||||
protected customRequest(
|
||||
command: string,
|
||||
response: CodeQLProtocol.Response,
|
||||
args: any,
|
||||
request?: Protocol.Request,
|
||||
): void {
|
||||
switch (command) {
|
||||
case "codeql-quickeval": {
|
||||
this.quickEvalRequest(
|
||||
response,
|
||||
args as CodeQLProtocol.QuickEvalRequest["arguments"],
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
default:
|
||||
super.customRequest(command, response, args, request);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
protected quickEvalRequest(
|
||||
response: CodeQLProtocol.QuickEvalResponse,
|
||||
args: CodeQLProtocol.QuickEvalRequest["arguments"],
|
||||
): void {
|
||||
switch (this.state) {
|
||||
case "stopped":
|
||||
// Send the response immediately. We'll send a "stopped" message when the evaluation is complete.
|
||||
this.sendResponse(response);
|
||||
|
||||
// For built-in requests that are expected to cause execution (`launch`, `continue`, `step`, etc.),
|
||||
// the adapter does not send a `continued` event because the client already knows that's what
|
||||
// is supposed to happen. For a custom request, though, we have to notify the client.
|
||||
this.sendEvent(new ContinuedEvent(QUERY_THREAD_ID, true));
|
||||
|
||||
void this.evaluate(args.quickEvalContext);
|
||||
break;
|
||||
|
||||
default:
|
||||
this.unexpectedState(response);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs the query or quickeval, and notifies the debugger client when the evaluation completes.
|
||||
*
|
||||
* This function is invoked from the `launch` and `continue` handlers, without awaiting its
|
||||
* result.
|
||||
*/
|
||||
private async evaluate(
|
||||
quickEvalContext: QuickEvalContext | undefined,
|
||||
): Promise<void> {
|
||||
const args = this.args!;
|
||||
|
||||
const runningQuery = new RunningQuery(
|
||||
this.queryRunner,
|
||||
args,
|
||||
quickEvalContext,
|
||||
this.queryStorageDir,
|
||||
this.logger,
|
||||
(event) => {
|
||||
// If `this.runningQuery` is undefined, it means that we've already disconnected from this
|
||||
// evaluation, and do not want any further events.
|
||||
if (this.runningQuery !== undefined) {
|
||||
this.sendEvent(event);
|
||||
}
|
||||
},
|
||||
);
|
||||
this.runningQuery = runningQuery;
|
||||
this.state = "running";
|
||||
|
||||
try {
|
||||
const result = await runningQuery.evaluate();
|
||||
this.completeEvaluation(result);
|
||||
} finally {
|
||||
this.runningQuery = undefined;
|
||||
runningQuery.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark the evaluation as completed, and notify the client of the result.
|
||||
*/
|
||||
private completeEvaluation(
|
||||
result: CodeQLProtocol.EvaluationCompletedEvent["body"],
|
||||
): void {
|
||||
this.lastResultType = result.resultType;
|
||||
|
||||
// Report the evaluation result
|
||||
this.sendEvent(new EvaluationCompletedEvent(result));
|
||||
if (result.resultType !== QueryResultType.SUCCESS) {
|
||||
// Report the result message as "important" output
|
||||
const message = result.message ?? "Unknown error";
|
||||
const outputEvent = new OutputEvent(message, "console");
|
||||
this.sendEvent(outputEvent);
|
||||
}
|
||||
|
||||
this.reportStopped();
|
||||
}
|
||||
|
||||
private reportStopped(): void {
|
||||
if (this.terminateOnComplete) {
|
||||
this.terminateAndExit();
|
||||
} else {
|
||||
// Report the session as "stopped", but keep the session open.
|
||||
this.sendEvent(new StoppedEvent("entry", QUERY_THREAD_ID));
|
||||
|
||||
this.state = "stopped";
|
||||
}
|
||||
}
|
||||
|
||||
private terminateAndExit(): void {
|
||||
// Report the debugging session as terminated.
|
||||
this.sendEvent(new TerminatedEvent());
|
||||
|
||||
// Report the debuggee as exited.
|
||||
this.sendEvent(new ExitedEvent(this.lastResultType));
|
||||
|
||||
this.state = "terminated";
|
||||
}
|
||||
}
|
||||
50
extensions/ql-vscode/src/debugger/debugger-factory.ts
Normal file
50
extensions/ql-vscode/src/debugger/debugger-factory.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import {
|
||||
debug,
|
||||
DebugAdapterDescriptor,
|
||||
DebugAdapterDescriptorFactory,
|
||||
DebugAdapterExecutable,
|
||||
DebugAdapterInlineImplementation,
|
||||
DebugConfigurationProviderTriggerKind,
|
||||
DebugSession,
|
||||
ProviderResult,
|
||||
} from "vscode";
|
||||
import { isCanary } from "../config";
|
||||
import { LocalQueries } from "../local-queries";
|
||||
import { DisposableObject } from "../pure/disposable-object";
|
||||
import { QueryRunner } from "../queryRunner";
|
||||
import { QLDebugConfigurationProvider } from "./debug-configuration";
|
||||
import { QLDebugSession } from "./debug-session";
|
||||
|
||||
export class QLDebugAdapterDescriptorFactory
|
||||
extends DisposableObject
|
||||
implements DebugAdapterDescriptorFactory
|
||||
{
|
||||
constructor(
|
||||
private readonly queryStorageDir: string,
|
||||
private readonly queryRunner: QueryRunner,
|
||||
localQueries: LocalQueries,
|
||||
) {
|
||||
super();
|
||||
|
||||
this.push(debug.registerDebugAdapterDescriptorFactory("codeql", this));
|
||||
this.push(
|
||||
debug.registerDebugConfigurationProvider(
|
||||
"codeql",
|
||||
new QLDebugConfigurationProvider(localQueries),
|
||||
DebugConfigurationProviderTriggerKind.Dynamic,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
public createDebugAdapterDescriptor(
|
||||
_session: DebugSession,
|
||||
_executable: DebugAdapterExecutable | undefined,
|
||||
): ProviderResult<DebugAdapterDescriptor> {
|
||||
if (!isCanary()) {
|
||||
throw new Error("The CodeQL debugger feature is not available yet.");
|
||||
}
|
||||
return new DebugAdapterInlineImplementation(
|
||||
new QLDebugSession(this.queryStorageDir, this.queryRunner),
|
||||
);
|
||||
}
|
||||
}
|
||||
235
extensions/ql-vscode/src/debugger/debugger-ui.ts
Normal file
235
extensions/ql-vscode/src/debugger/debugger-ui.ts
Normal file
@@ -0,0 +1,235 @@
|
||||
import { basename } from "path";
|
||||
import {
|
||||
DebugAdapterTracker,
|
||||
DebugAdapterTrackerFactory,
|
||||
DebugSession,
|
||||
debug,
|
||||
Uri,
|
||||
CancellationTokenSource,
|
||||
} from "vscode";
|
||||
import { DebuggerCommands } from "../common/commands";
|
||||
import { DatabaseManager } from "../local-databases";
|
||||
import { LocalQueries, LocalQueryRun } from "../local-queries";
|
||||
import { DisposableObject } from "../pure/disposable-object";
|
||||
import { CoreQueryResults } from "../queryRunner";
|
||||
import {
|
||||
getQuickEvalContext,
|
||||
QueryOutputDir,
|
||||
validateQueryUri,
|
||||
} from "../run-queries-shared";
|
||||
import { QLResolvedDebugConfiguration } from "./debug-configuration";
|
||||
import * as CodeQLProtocol from "./debug-protocol";
|
||||
import { App } from "../common/app";
|
||||
|
||||
/**
|
||||
* Listens to messages passing between VS Code and the debug adapter, so that we can supplement the
|
||||
* UI.
|
||||
*/
|
||||
class QLDebugAdapterTracker
|
||||
extends DisposableObject
|
||||
implements DebugAdapterTracker
|
||||
{
|
||||
private readonly configuration: QLResolvedDebugConfiguration;
|
||||
/** The `LocalQueryRun` of the current evaluation, if one is running. */
|
||||
private localQueryRun: LocalQueryRun | undefined;
|
||||
/** The promise of the most recently queued deferred message handler. */
|
||||
private lastDeferredMessageHandler: Promise<void> = Promise.resolve();
|
||||
|
||||
constructor(
|
||||
private readonly session: DebugSession,
|
||||
private readonly ui: DebuggerUI,
|
||||
private readonly localQueries: LocalQueries,
|
||||
private readonly dbm: DatabaseManager,
|
||||
) {
|
||||
super();
|
||||
this.configuration = <QLResolvedDebugConfiguration>session.configuration;
|
||||
}
|
||||
|
||||
public onDidSendMessage(message: CodeQLProtocol.AnyProtocolMessage): void {
|
||||
if (message.type === "event") {
|
||||
switch (message.event) {
|
||||
case "codeql-evaluation-started":
|
||||
this.queueMessageHandler(() =>
|
||||
this.onEvaluationStarted(message.body),
|
||||
);
|
||||
break;
|
||||
case "codeql-evaluation-completed":
|
||||
this.queueMessageHandler(() =>
|
||||
this.onEvaluationCompleted(message.body),
|
||||
);
|
||||
break;
|
||||
case "output":
|
||||
if (message.body.category === "console") {
|
||||
void this.localQueryRun?.logger.log(message.body.output);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public onWillStopSession(): void {
|
||||
this.ui.onSessionClosed(this.session);
|
||||
this.dispose();
|
||||
}
|
||||
|
||||
public async quickEval(): Promise<void> {
|
||||
const args: CodeQLProtocol.QuickEvalRequest["arguments"] = {
|
||||
quickEvalContext: await getQuickEvalContext(undefined),
|
||||
};
|
||||
await this.session.customRequest("codeql-quickeval", args);
|
||||
}
|
||||
|
||||
/**
|
||||
* Queues a message handler to be executed once all other pending message handlers have completed.
|
||||
*
|
||||
* The `onDidSendMessage()` function is synchronous, so it needs to return before any async
|
||||
* handling of the msssage is completed. We can't just launch the message handler directly from
|
||||
* `onDidSendMessage()`, though, because if the message handler's implementation blocks awaiting
|
||||
* a promise, then another event might be received by `onDidSendMessage()` while the first message
|
||||
* handler is still incomplete.
|
||||
*
|
||||
* To enforce sequential execution of event handlers, we queue each new handler as a `finally()`
|
||||
* handler for the most recently queued message.
|
||||
*/
|
||||
private queueMessageHandler(handler: () => Promise<void>): void {
|
||||
this.lastDeferredMessageHandler =
|
||||
this.lastDeferredMessageHandler.finally(handler);
|
||||
}
|
||||
|
||||
/** Updates the UI to track the currently executing query. */
|
||||
private async onEvaluationStarted(
|
||||
body: CodeQLProtocol.EvaluationStartedEvent["body"],
|
||||
): Promise<void> {
|
||||
const dbUri = Uri.file(this.configuration.database);
|
||||
const dbItem = await this.dbm.createOrOpenDatabaseItem(dbUri);
|
||||
|
||||
// When cancellation is requested from the query history view, we just stop the debug session.
|
||||
const tokenSource = new CancellationTokenSource();
|
||||
tokenSource.token.onCancellationRequested(() =>
|
||||
debug.stopDebugging(this.session),
|
||||
);
|
||||
|
||||
this.localQueryRun = await this.localQueries.createLocalQueryRun(
|
||||
{
|
||||
queryPath: this.configuration.query,
|
||||
quickEval: body.quickEvalContext,
|
||||
},
|
||||
dbItem,
|
||||
new QueryOutputDir(body.outputDir),
|
||||
tokenSource,
|
||||
);
|
||||
}
|
||||
|
||||
/** Update the UI after a query has finished evaluating. */
|
||||
private async onEvaluationCompleted(
|
||||
body: CodeQLProtocol.EvaluationCompletedEvent["body"],
|
||||
): Promise<void> {
|
||||
if (this.localQueryRun !== undefined) {
|
||||
const results: CoreQueryResults = body;
|
||||
await this.localQueryRun.complete(results);
|
||||
this.localQueryRun = undefined;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Service handling the UI for CodeQL debugging. */
|
||||
export class DebuggerUI
|
||||
extends DisposableObject
|
||||
implements DebugAdapterTrackerFactory
|
||||
{
|
||||
private readonly sessions = new Map<string, QLDebugAdapterTracker>();
|
||||
|
||||
constructor(
|
||||
private readonly app: App,
|
||||
private readonly localQueries: LocalQueries,
|
||||
private readonly dbm: DatabaseManager,
|
||||
) {
|
||||
super();
|
||||
|
||||
this.push(debug.registerDebugAdapterTrackerFactory("codeql", this));
|
||||
}
|
||||
|
||||
public getCommands(): DebuggerCommands {
|
||||
return {
|
||||
"codeQL.debugQuery": this.debugQuery.bind(this),
|
||||
"codeQL.debugQueryContextEditor": this.debugQuery.bind(this),
|
||||
"codeQL.startDebuggingSelectionContextEditor":
|
||||
this.startDebuggingSelection.bind(this),
|
||||
"codeQL.startDebuggingSelection": this.startDebuggingSelection.bind(this),
|
||||
"codeQL.continueDebuggingSelection":
|
||||
this.continueDebuggingSelection.bind(this),
|
||||
"codeQL.continueDebuggingSelectionContextEditor":
|
||||
this.continueDebuggingSelection.bind(this),
|
||||
};
|
||||
}
|
||||
|
||||
public createDebugAdapterTracker(
|
||||
session: DebugSession,
|
||||
): DebugAdapterTracker | undefined {
|
||||
if (session.type === "codeql") {
|
||||
// The tracker will be disposed in its own `onWillStopSession` handler.
|
||||
const tracker = new QLDebugAdapterTracker(
|
||||
session,
|
||||
this,
|
||||
this.localQueries,
|
||||
this.dbm,
|
||||
);
|
||||
this.sessions.set(session.id, tracker);
|
||||
return tracker;
|
||||
} else {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
public onSessionClosed(session: DebugSession): void {
|
||||
this.sessions.delete(session.id);
|
||||
}
|
||||
|
||||
private async debugQuery(uri: Uri | undefined): Promise<void> {
|
||||
const queryPath =
|
||||
uri !== undefined
|
||||
? validateQueryUri(uri, false)
|
||||
: await this.localQueries.getCurrentQuery(false);
|
||||
|
||||
// Start debugging with a default configuration that just specifies the query path.
|
||||
await debug.startDebugging(undefined, {
|
||||
name: basename(queryPath),
|
||||
type: "codeql",
|
||||
request: "launch",
|
||||
query: queryPath,
|
||||
});
|
||||
}
|
||||
|
||||
private async startDebuggingSelection(): Promise<void> {
|
||||
// Launch the currently selected debug configuration, but specifying QuickEval mode.
|
||||
await this.app.commands.execute("workbench.action.debug.start", {
|
||||
config: {
|
||||
quickEval: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private async continueDebuggingSelection(): Promise<void> {
|
||||
const activeTracker = this.activeTracker;
|
||||
if (activeTracker === undefined) {
|
||||
throw new Error("No CodeQL debug session is active.");
|
||||
}
|
||||
|
||||
await activeTracker.quickEval();
|
||||
}
|
||||
|
||||
private getTrackerForSession(
|
||||
session: DebugSession,
|
||||
): QLDebugAdapterTracker | undefined {
|
||||
return this.sessions.get(session.id);
|
||||
}
|
||||
|
||||
public get activeTracker(): QLDebugAdapterTracker | undefined {
|
||||
const session = debug.activeDebugSession;
|
||||
if (session === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return this.getTrackerForSession(session);
|
||||
}
|
||||
}
|
||||
@@ -31,6 +31,7 @@ import { CodeQLCliServer } from "./cli";
|
||||
import {
|
||||
CliConfigListener,
|
||||
DistributionConfigListener,
|
||||
isCanary,
|
||||
joinOrderWarningThreshold,
|
||||
QueryHistoryConfigListener,
|
||||
QueryServerConfigListener,
|
||||
@@ -108,20 +109,24 @@ import { VariantAnalysisResultsManager } from "./variant-analysis/variant-analys
|
||||
import { ExtensionApp } from "./common/vscode/vscode-app";
|
||||
import { DbModule } from "./databases/db-module";
|
||||
import { redactableError } from "./pure/errors";
|
||||
import { QLDebugAdapterDescriptorFactory } from "./debugger/debugger-factory";
|
||||
import { QueryHistoryDirs } from "./query-history/query-history-dirs";
|
||||
import {
|
||||
AllExtensionCommands,
|
||||
BaseCommands,
|
||||
PreActivationCommands,
|
||||
QueryServerCommands,
|
||||
TestUICommands,
|
||||
} from "./common/commands";
|
||||
import { LocalQueries } from "./local-queries";
|
||||
import { getAstCfgCommands } from "./ast-cfg-commands";
|
||||
import { getQueryEditorCommands } from "./query-editor";
|
||||
import { App } from "./common/app";
|
||||
import { registerCommandWithErrorHandling } from "./common/vscode/commands";
|
||||
import { DebuggerUI } from "./debugger/debugger-ui";
|
||||
import { DataExtensionsEditorModule } from "./data-extensions-editor/data-extensions-editor-module";
|
||||
import { TestManager } from "./test-manager";
|
||||
import { TestRunner } from "./test-runner";
|
||||
import { TestManagerBase } from "./test-manager-base";
|
||||
|
||||
/**
|
||||
* extension.ts
|
||||
@@ -177,7 +182,13 @@ function getCommands(
|
||||
cliServer.restartCliServer();
|
||||
await Promise.all([
|
||||
queryRunner.restartQueryServer(progress, token),
|
||||
ideServer.restart(),
|
||||
async () => {
|
||||
if (ideServer.isRunning()) {
|
||||
await ideServer.restart();
|
||||
} else {
|
||||
await ideServer.start();
|
||||
}
|
||||
},
|
||||
]);
|
||||
void showAndLogInformationMessage("CodeQL Query Server restarted.", {
|
||||
outputLogger: queryServerLogger,
|
||||
@@ -868,6 +879,15 @@ async function activateWithInstalledDistribution(
|
||||
);
|
||||
ctx.subscriptions.push(localQueries);
|
||||
|
||||
void extLogger.log("Initializing debugger factory.");
|
||||
ctx.subscriptions.push(
|
||||
new QLDebugAdapterDescriptorFactory(queryStorageDir, qs, localQueries),
|
||||
);
|
||||
|
||||
void extLogger.log("Initializing debugger UI.");
|
||||
const debuggerUI = new DebuggerUI(app, localQueries, dbm);
|
||||
ctx.subscriptions.push(debuggerUI);
|
||||
|
||||
const dataExtensionsEditorModule =
|
||||
await DataExtensionsEditorModule.initialize(
|
||||
ctx,
|
||||
@@ -879,25 +899,34 @@ async function activateWithInstalledDistribution(
|
||||
);
|
||||
|
||||
void extLogger.log("Initializing QLTest interface.");
|
||||
const testExplorerExtension = extensions.getExtension<TestHub>(
|
||||
testExplorerExtensionId,
|
||||
);
|
||||
let testUiCommands: Partial<TestUICommands> = {};
|
||||
if (testExplorerExtension) {
|
||||
const testHub = testExplorerExtension.exports;
|
||||
const testAdapterFactory = new QLTestAdapterFactory(
|
||||
testHub,
|
||||
cliServer,
|
||||
dbm,
|
||||
|
||||
const testRunner = new TestRunner(dbm, cliServer);
|
||||
ctx.subscriptions.push(testRunner);
|
||||
|
||||
let testManager: TestManagerBase | undefined = undefined;
|
||||
if (isCanary()) {
|
||||
testManager = new TestManager(app, testRunner, cliServer);
|
||||
ctx.subscriptions.push(testManager);
|
||||
} else {
|
||||
const testExplorerExtension = extensions.getExtension<TestHub>(
|
||||
testExplorerExtensionId,
|
||||
);
|
||||
ctx.subscriptions.push(testAdapterFactory);
|
||||
if (testExplorerExtension) {
|
||||
const testHub = testExplorerExtension.exports;
|
||||
const testAdapterFactory = new QLTestAdapterFactory(
|
||||
testHub,
|
||||
testRunner,
|
||||
cliServer,
|
||||
);
|
||||
ctx.subscriptions.push(testAdapterFactory);
|
||||
|
||||
const testUIService = new TestUIService(app, testHub);
|
||||
ctx.subscriptions.push(testUIService);
|
||||
|
||||
testUiCommands = testUIService.getCommands();
|
||||
testManager = new TestUIService(app, testHub);
|
||||
ctx.subscriptions.push(testManager);
|
||||
}
|
||||
}
|
||||
|
||||
const testUiCommands = testManager?.getCommands() ?? {};
|
||||
|
||||
const astViewer = new AstViewer();
|
||||
const astTemplateProvider = new TemplatePrintAstProvider(
|
||||
cliServer,
|
||||
@@ -945,6 +974,7 @@ async function activateWithInstalledDistribution(
|
||||
...summaryLanguageSupport.getCommands(),
|
||||
...testUiCommands,
|
||||
...mockServer.getCommands(),
|
||||
...debuggerUI.getCommands(),
|
||||
};
|
||||
|
||||
for (const [commandName, command] of Object.entries(allCommands)) {
|
||||
|
||||
@@ -23,6 +23,7 @@ export class ServerProcess implements Disposable {
|
||||
dispose(): void {
|
||||
void this.logger.log(`Stopping ${this.name}...`);
|
||||
this.connection.dispose();
|
||||
this.connection.end();
|
||||
this.child.stdin!.end();
|
||||
this.child.stderr!.destroy();
|
||||
// TODO kill the process if it doesn't terminate after a certain time limit.
|
||||
|
||||
@@ -316,7 +316,7 @@ export async function compileAndRunQueryAgainstDatabaseCore(
|
||||
logger: Logger,
|
||||
): Promise<CoreQueryResults> {
|
||||
if (extensionPacks !== undefined && extensionPacks.length > 0) {
|
||||
await showAndLogWarningMessage(
|
||||
void showAndLogWarningMessage(
|
||||
"Legacy query server does not support extension packs.",
|
||||
);
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
ThemeIcon,
|
||||
ThemeColor,
|
||||
workspace,
|
||||
ProgressLocation,
|
||||
} from "vscode";
|
||||
import { pathExists, stat, readdir, remove } from "fs-extra";
|
||||
|
||||
@@ -21,7 +22,12 @@ import {
|
||||
DatabaseItem,
|
||||
DatabaseManager,
|
||||
} from "./local-databases";
|
||||
import { ProgressCallback, withProgress } from "./progress";
|
||||
import {
|
||||
ProgressCallback,
|
||||
ProgressContext,
|
||||
withInheritedProgress,
|
||||
withProgress,
|
||||
} from "./progress";
|
||||
import {
|
||||
isLikelyDatabaseRoot,
|
||||
isLikelyDbLanguageFolder,
|
||||
@@ -208,6 +214,7 @@ export class DatabaseUI extends DisposableObject {
|
||||
|
||||
public getCommands(): LocalDatabasesCommands {
|
||||
return {
|
||||
"codeQL.getCurrentDatabase": this.handleGetCurrentDatabase.bind(this),
|
||||
"codeQL.chooseDatabaseFolder":
|
||||
this.handleChooseDatabaseFolderFromPalette.bind(this),
|
||||
"codeQL.chooseDatabaseArchive":
|
||||
@@ -254,7 +261,7 @@ export class DatabaseUI extends DisposableObject {
|
||||
token: CancellationToken,
|
||||
): Promise<void> {
|
||||
try {
|
||||
await this.chooseAndSetDatabase(true, progress, token);
|
||||
await this.chooseAndSetDatabase(true, { progress, token });
|
||||
} catch (e) {
|
||||
void showAndLogExceptionWithTelemetry(
|
||||
redactableError(
|
||||
@@ -415,7 +422,7 @@ export class DatabaseUI extends DisposableObject {
|
||||
token: CancellationToken,
|
||||
): Promise<void> {
|
||||
try {
|
||||
await this.chooseAndSetDatabase(false, progress, token);
|
||||
await this.chooseAndSetDatabase(false, { progress, token });
|
||||
} catch (e: unknown) {
|
||||
void showAndLogExceptionWithTelemetry(
|
||||
redactableError(
|
||||
@@ -602,6 +609,11 @@ export class DatabaseUI extends DisposableObject {
|
||||
);
|
||||
}
|
||||
|
||||
private async handleGetCurrentDatabase(): Promise<string | undefined> {
|
||||
const dbItem = await this.getDatabaseItemInternal(undefined);
|
||||
return dbItem?.databaseUri.fsPath;
|
||||
}
|
||||
|
||||
private async handleSetCurrentDatabase(uri: Uri): Promise<void> {
|
||||
return withProgress(
|
||||
async (progress, token) => {
|
||||
@@ -717,9 +729,24 @@ export class DatabaseUI extends DisposableObject {
|
||||
public async getDatabaseItem(
|
||||
progress: ProgressCallback,
|
||||
token: CancellationToken,
|
||||
): Promise<DatabaseItem | undefined> {
|
||||
return await this.getDatabaseItemInternal({ progress, token });
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the current database directory. If we don't already have a
|
||||
* current database, ask the user for one, and return that, or
|
||||
* undefined if they cancel.
|
||||
*
|
||||
* Unlike `getDatabaseItem()`, this function does not require the caller to pass in a progress
|
||||
* context. If `progress` is `undefined`, then this command will create a new progress
|
||||
* notification if it tries to perform any long-running operations.
|
||||
*/
|
||||
private async getDatabaseItemInternal(
|
||||
progress: ProgressContext | undefined,
|
||||
): Promise<DatabaseItem | undefined> {
|
||||
if (this.databaseManager.currentDatabaseItem === undefined) {
|
||||
await this.chooseAndSetDatabase(false, progress, token);
|
||||
await this.chooseAndSetDatabase(false, progress);
|
||||
}
|
||||
|
||||
return this.databaseManager.currentDatabaseItem;
|
||||
@@ -749,31 +776,40 @@ export class DatabaseUI extends DisposableObject {
|
||||
*/
|
||||
private async chooseAndSetDatabase(
|
||||
byFolder: boolean,
|
||||
progress: ProgressCallback,
|
||||
token: CancellationToken,
|
||||
progress: ProgressContext | undefined,
|
||||
): Promise<DatabaseItem | undefined> {
|
||||
const uri = await chooseDatabaseDir(byFolder);
|
||||
if (!uri) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (byFolder) {
|
||||
const fixedUri = await this.fixDbUri(uri);
|
||||
// we are selecting a database folder
|
||||
return await this.setCurrentDatabase(progress, token, fixedUri);
|
||||
} else {
|
||||
// we are selecting a database archive. Must unzip into a workspace-controlled area
|
||||
// before importing.
|
||||
return await importArchiveDatabase(
|
||||
this.app.commands,
|
||||
uri.toString(true),
|
||||
this.databaseManager,
|
||||
this.storagePath,
|
||||
progress,
|
||||
token,
|
||||
this.queryServer?.cliServer,
|
||||
);
|
||||
}
|
||||
return await withInheritedProgress(
|
||||
progress,
|
||||
async (progress, token) => {
|
||||
if (byFolder) {
|
||||
const fixedUri = await this.fixDbUri(uri);
|
||||
// we are selecting a database folder
|
||||
return await this.setCurrentDatabase(progress, token, fixedUri);
|
||||
} else {
|
||||
// we are selecting a database archive. Must unzip into a workspace-controlled area
|
||||
// before importing.
|
||||
return await importArchiveDatabase(
|
||||
this.app.commands,
|
||||
uri.toString(true),
|
||||
this.databaseManager,
|
||||
this.storagePath,
|
||||
progress,
|
||||
token,
|
||||
this.queryServer?.cliServer,
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
location: ProgressLocation.Notification,
|
||||
cancellable: true,
|
||||
title: "Opening database",
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -613,12 +613,61 @@ export class DatabaseManager extends DisposableObject {
|
||||
qs.onStart(this.reregisterDatabases.bind(this));
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a {@link DatabaseItem} for the specified database, and adds it to the list of open
|
||||
* databases.
|
||||
*/
|
||||
public async openDatabase(
|
||||
progress: ProgressCallback,
|
||||
token: vscode.CancellationToken,
|
||||
uri: vscode.Uri,
|
||||
displayName?: string,
|
||||
isTutorialDatabase?: boolean,
|
||||
): Promise<DatabaseItem> {
|
||||
const databaseItem = await this.createDatabaseItem(uri, displayName);
|
||||
|
||||
return await this.addExistingDatabaseItem(
|
||||
databaseItem,
|
||||
progress,
|
||||
token,
|
||||
isTutorialDatabase,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a {@link DatabaseItem} to the list of open databases, if that database is not already on
|
||||
* the list.
|
||||
*
|
||||
* Typically, the item will have been created by {@link createOrOpenDatabaseItem} or {@link openDatabase}.
|
||||
*/
|
||||
public async addExistingDatabaseItem(
|
||||
databaseItem: DatabaseItem,
|
||||
progress: ProgressCallback,
|
||||
token: vscode.CancellationToken,
|
||||
isTutorialDatabase?: boolean,
|
||||
): Promise<DatabaseItem> {
|
||||
const existingItem = this.findDatabaseItem(databaseItem.databaseUri);
|
||||
if (existingItem !== undefined) {
|
||||
return existingItem;
|
||||
}
|
||||
|
||||
await this.addDatabaseItem(progress, token, databaseItem);
|
||||
await this.addDatabaseSourceArchiveFolder(databaseItem);
|
||||
|
||||
if (isCodespacesTemplate() && !isTutorialDatabase) {
|
||||
await this.createSkeletonPacks(databaseItem);
|
||||
}
|
||||
|
||||
return databaseItem;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a {@link DatabaseItem} for the specified database, without adding it to the list of
|
||||
* open databases.
|
||||
*/
|
||||
private async createDatabaseItem(
|
||||
uri: vscode.Uri,
|
||||
displayName: string | undefined,
|
||||
): Promise<DatabaseItem> {
|
||||
const contents = await DatabaseResolver.resolveDatabaseContents(uri);
|
||||
// Ignore the source archive for QLTest databases by default.
|
||||
@@ -639,14 +688,27 @@ export class DatabaseManager extends DisposableObject {
|
||||
},
|
||||
);
|
||||
|
||||
await this.addDatabaseItem(progress, token, databaseItem);
|
||||
await this.addDatabaseSourceArchiveFolder(databaseItem);
|
||||
return databaseItem;
|
||||
}
|
||||
|
||||
if (isCodespacesTemplate() && !isTutorialDatabase) {
|
||||
await this.createSkeletonPacks(databaseItem);
|
||||
/**
|
||||
* If the specified database is already on the list of open databases, returns that database's
|
||||
* {@link DatabaseItem}. Otherwise, creates a new {@link DatabaseItem} without adding it to the
|
||||
* list of open databases.
|
||||
*
|
||||
* The {@link DatabaseItem} can be added to the list of open databases later, via {@link addExistingDatabaseItem}.
|
||||
*/
|
||||
public async createOrOpenDatabaseItem(
|
||||
uri: vscode.Uri,
|
||||
): Promise<DatabaseItem> {
|
||||
const existingItem = this.findDatabaseItem(uri);
|
||||
if (existingItem !== undefined) {
|
||||
// Use the one we already have.
|
||||
return existingItem;
|
||||
}
|
||||
|
||||
return databaseItem;
|
||||
// We don't add this to the list automatically, but the user can add it later.
|
||||
return this.createDatabaseItem(uri, undefined);
|
||||
}
|
||||
|
||||
public async createSkeletonPacks(databaseItem: DatabaseItem) {
|
||||
@@ -1029,7 +1091,19 @@ export class DatabaseManager extends DisposableObject {
|
||||
token: vscode.CancellationToken,
|
||||
dbItem: DatabaseItem,
|
||||
) {
|
||||
await this.qs.deregisterDatabase(progress, token, dbItem);
|
||||
try {
|
||||
await this.qs.deregisterDatabase(progress, token, dbItem);
|
||||
} catch (e) {
|
||||
const message = getErrorMessage(e);
|
||||
if (message === "Connection is disposed.") {
|
||||
// This is expected if the query server is not running.
|
||||
void extLogger.log(
|
||||
`Could not de-register database '${dbItem.name}' because query server is not running.`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
private async registerDatabase(
|
||||
progress: ProgressCallback,
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
Range,
|
||||
Uri,
|
||||
window,
|
||||
workspace,
|
||||
} from "vscode";
|
||||
import { BaseLogger, extLogger, Logger, TeeLogger } from "./common";
|
||||
import { isCanary, MAX_QUERIES } from "./config";
|
||||
@@ -33,14 +34,16 @@ import { ResultsView } from "./interface";
|
||||
import { DatabaseItem, DatabaseManager } from "./local-databases";
|
||||
import {
|
||||
createInitialQueryInfo,
|
||||
determineSelectedQuery,
|
||||
EvaluatorLogPaths,
|
||||
generateEvalLogSummaries,
|
||||
getQuickEvalContext,
|
||||
logEndSummary,
|
||||
promptUserToSaveChanges,
|
||||
QueryEvaluationInfo,
|
||||
QueryOutputDir,
|
||||
QueryWithResults,
|
||||
SelectedQuery,
|
||||
validateQueryUri,
|
||||
} from "./run-queries-shared";
|
||||
import { CompletedLocalQueryInfo, LocalQueryInfo } from "./query-results";
|
||||
import { WebviewReveal } from "./interface-utils";
|
||||
@@ -75,6 +78,25 @@ function formatResultMessage(result: CoreQueryResults): string {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* If either the query file or the quickeval file is dirty, give the user the chance to save them.
|
||||
*/
|
||||
async function promptToSaveQueryIfNeeded(query: SelectedQuery): Promise<void> {
|
||||
// There seems to be no way to ask VS Code to find an existing text document by name, without
|
||||
// automatically opening the document if it is not found.
|
||||
const queryUri = Uri.file(query.queryPath).toString();
|
||||
const quickEvalUri =
|
||||
query.quickEval !== undefined
|
||||
? Uri.file(query.quickEval.quickEvalPosition.fileName).toString()
|
||||
: undefined;
|
||||
for (const openDocument of workspace.textDocuments) {
|
||||
const documentUri = openDocument.uri.toString();
|
||||
if (documentUri === queryUri || documentUri === quickEvalUri) {
|
||||
await promptUserToSaveChanges(openDocument);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Tracks the evaluation of a local query, including its interactions with the UI.
|
||||
*
|
||||
@@ -238,6 +260,13 @@ export class LocalQueries extends DisposableObject {
|
||||
"codeQL.quickEvalContextEditor": this.quickEval.bind(this),
|
||||
"codeQL.codeLensQuickEval": this.codeLensQuickEval.bind(this),
|
||||
"codeQL.quickQuery": this.quickQuery.bind(this),
|
||||
"codeQL.getCurrentQuery": () => {
|
||||
// When invoked as a command, such as when resolving variables in a debug configuration,
|
||||
// always allow ".qll" files, because we don't know if the configuration will be for
|
||||
// quickeval yet. The debug configuration code will do further validation once it knows for
|
||||
// sure.
|
||||
return this.getCurrentQuery(true);
|
||||
},
|
||||
"codeQL.createQuery": this.createSkeletonQuery.bind(this),
|
||||
};
|
||||
}
|
||||
@@ -377,6 +406,23 @@ export class LocalQueries extends DisposableObject {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the current active query.
|
||||
*
|
||||
* For now, the "active query" is just whatever query is in the active text editor. Once we have a
|
||||
* propery "queries" panel, we can provide a way to select the current query there.
|
||||
*/
|
||||
public async getCurrentQuery(allowLibraryFiles: boolean): Promise<string> {
|
||||
const editor = window.activeTextEditor;
|
||||
if (editor === undefined) {
|
||||
throw new Error(
|
||||
"No query was selected. Please select a query and try again.",
|
||||
);
|
||||
}
|
||||
|
||||
return validateQueryUri(editor.document.uri, allowLibraryFiles);
|
||||
}
|
||||
|
||||
private async createSkeletonQuery(): Promise<void> {
|
||||
await withProgress(
|
||||
async (progress: ProgressCallback, token: CancellationToken) => {
|
||||
@@ -470,29 +516,38 @@ export class LocalQueries extends DisposableObject {
|
||||
databaseItem: DatabaseItem | undefined,
|
||||
range?: Range,
|
||||
): Promise<CoreCompletedQuery> {
|
||||
const selectedQuery = await determineSelectedQuery(
|
||||
queryUri,
|
||||
quickEval,
|
||||
range,
|
||||
);
|
||||
let queryPath: string;
|
||||
if (queryUri !== undefined) {
|
||||
// The query URI is provided by the command, most likely because the command was run from an
|
||||
// editor context menu. Use the provided URI, but make sure it's a valid query.
|
||||
queryPath = validateQueryUri(queryUri, quickEval);
|
||||
} else {
|
||||
// Use the currently selected query.
|
||||
queryPath = await this.getCurrentQuery(quickEval);
|
||||
}
|
||||
|
||||
const selectedQuery: SelectedQuery = {
|
||||
queryPath,
|
||||
quickEval: quickEval ? await getQuickEvalContext(range) : undefined,
|
||||
};
|
||||
|
||||
// If no databaseItem is specified, use the database currently selected in the Databases UI
|
||||
databaseItem =
|
||||
databaseItem || (await this.databaseUI.getDatabaseItem(progress, token));
|
||||
databaseItem ?? (await this.databaseUI.getDatabaseItem(progress, token));
|
||||
if (databaseItem === undefined) {
|
||||
throw new Error("Can't run query without a selected database");
|
||||
}
|
||||
|
||||
const additionalPacks = getOnDiskWorkspaceFolders();
|
||||
const extensionPacks = (await this.cliServer.useExtensionPacks())
|
||||
? Object.keys(await this.cliServer.resolveQlpacks(additionalPacks, true))
|
||||
: undefined;
|
||||
const extensionPacks = await this.getDefaultExtensionPacks(additionalPacks);
|
||||
|
||||
await promptToSaveQueryIfNeeded(selectedQuery);
|
||||
|
||||
const coreQueryRun = this.queryRunner.createQueryRun(
|
||||
databaseItem.databaseUri.fsPath,
|
||||
{
|
||||
queryPath: selectedQuery.queryPath,
|
||||
quickEvalPosition: selectedQuery.quickEvalPosition,
|
||||
quickEvalPosition: selectedQuery.quickEval?.quickEvalPosition,
|
||||
},
|
||||
true,
|
||||
additionalPacks,
|
||||
@@ -612,4 +667,12 @@ export class LocalQueries extends DisposableObject {
|
||||
): Promise<void> {
|
||||
await this.localQueryResultsView.showResults(query, forceReveal, false);
|
||||
}
|
||||
|
||||
public async getDefaultExtensionPacks(
|
||||
additionalPacks: string[],
|
||||
): Promise<string[]> {
|
||||
return (await this.cliServer.useExtensionPacks())
|
||||
? Object.keys(await this.cliServer.resolveQlpacks(additionalPacks, true))
|
||||
: [];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -82,6 +82,29 @@ export function withProgress<R>(
|
||||
);
|
||||
}
|
||||
|
||||
export interface ProgressContext {
|
||||
progress: ProgressCallback;
|
||||
token: CancellationToken;
|
||||
}
|
||||
|
||||
/**
|
||||
* Like `withProgress()`, except that the caller is not required to provide a progress context. If
|
||||
* the caller does provide one, any long-running operations performed by `task` will use the
|
||||
* supplied progress context. Otherwise, this function wraps `task` in a new progress context with
|
||||
* the supplied options.
|
||||
*/
|
||||
export function withInheritedProgress<R>(
|
||||
parent: ProgressContext | undefined,
|
||||
task: ProgressTask<R>,
|
||||
options: ProgressOptions,
|
||||
): Thenable<R> {
|
||||
if (parent !== undefined) {
|
||||
return task(parent.progress, parent.token);
|
||||
} else {
|
||||
return withProgress(task, options);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Displays a progress monitor that indicates how much progess has been made
|
||||
* reading from a stream.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { ensureFile } from "fs-extra";
|
||||
|
||||
import { DisposableObject } from "../pure/disposable-object";
|
||||
import { DisposableObject, DisposeHandler } from "../pure/disposable-object";
|
||||
import { CancellationToken } from "vscode";
|
||||
import { createMessageConnection, RequestType } from "vscode-jsonrpc/node";
|
||||
import * as cli from "../cli";
|
||||
@@ -224,4 +224,10 @@ export class QueryServerClient extends DisposableObject {
|
||||
delete this.progressCallbacks[id];
|
||||
}
|
||||
}
|
||||
|
||||
public dispose(disposeHandler?: DisposeHandler | undefined): void {
|
||||
this.progressCallbacks = {};
|
||||
this.stopQueryServer();
|
||||
super.dispose(disposeHandler);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -389,55 +389,33 @@ export interface QueryWithResults {
|
||||
}
|
||||
|
||||
/**
|
||||
* Information about which query will be to be run. `quickEvalPosition` and `quickEvalText`
|
||||
* is only filled in if the query is a quick query.
|
||||
* Validates that the specified URI represents a QL query, and returns the file system path to that
|
||||
* query.
|
||||
*
|
||||
* If `allowLibraryFiles` is set, ".qll" files will also be allowed as query files.
|
||||
*/
|
||||
export interface SelectedQuery {
|
||||
queryPath: string;
|
||||
quickEvalPosition?: messages.Position;
|
||||
quickEvalText?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines which QL file to run during an invocation of `Run Query` or `Quick Evaluation`, as follows:
|
||||
* - If the command was called by clicking on a file, then use that file.
|
||||
* - Otherwise, use the file open in the current editor.
|
||||
* - In either case, prompt the user to save the file if it is open with unsaved changes.
|
||||
* - For `Quick Evaluation`, ensure the selected file is also the one open in the editor,
|
||||
* and use the selected region.
|
||||
* @param selectedResourceUri The selected resource when the command was run.
|
||||
* @param quickEval Whether the command being run is `Quick Evaluation`.
|
||||
*/
|
||||
export async function determineSelectedQuery(
|
||||
selectedResourceUri: Uri | undefined,
|
||||
quickEval: boolean,
|
||||
range?: Range,
|
||||
): Promise<SelectedQuery> {
|
||||
const editor = window.activeTextEditor;
|
||||
|
||||
// Choose which QL file to use.
|
||||
let queryUri: Uri;
|
||||
if (selectedResourceUri) {
|
||||
// A resource was passed to the command handler, so use it.
|
||||
queryUri = selectedResourceUri;
|
||||
} else {
|
||||
// No resource was passed to the command handler, so obtain it from the active editor.
|
||||
// This usually happens when the command is called from the Command Palette.
|
||||
if (editor === undefined) {
|
||||
throw new Error(
|
||||
"No query was selected. Please select a query and try again.",
|
||||
);
|
||||
} else {
|
||||
queryUri = editor.document.uri;
|
||||
}
|
||||
}
|
||||
|
||||
export function validateQueryUri(
|
||||
queryUri: Uri,
|
||||
allowLibraryFiles: boolean,
|
||||
): string {
|
||||
if (queryUri.scheme !== "file") {
|
||||
throw new Error("Can only run queries that are on disk.");
|
||||
}
|
||||
const queryPath = queryUri.fsPath;
|
||||
validateQueryPath(queryPath, allowLibraryFiles);
|
||||
return queryPath;
|
||||
}
|
||||
|
||||
if (quickEval) {
|
||||
/**
|
||||
* Validates that the specified path represents a QL query
|
||||
*
|
||||
* If `allowLibraryFiles` is set, ".qll" files will also be allowed as query files.
|
||||
*/
|
||||
export function validateQueryPath(
|
||||
queryPath: string,
|
||||
allowLibraryFiles: boolean,
|
||||
): void {
|
||||
if (allowLibraryFiles) {
|
||||
if (!(queryPath.endsWith(".ql") || queryPath.endsWith(".qll"))) {
|
||||
throw new Error(
|
||||
'The selected resource is not a CodeQL file; It should have the extension ".ql" or ".qll".',
|
||||
@@ -450,40 +428,52 @@ export async function determineSelectedQuery(
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Whether we chose the file from the active editor or from a context menu,
|
||||
// if the same file is open with unsaved changes in the active editor,
|
||||
// then prompt the user to save it first.
|
||||
if (editor !== undefined && editor.document.uri.fsPath === queryPath) {
|
||||
if (await promptUserToSaveChanges(editor.document)) {
|
||||
await editor.document.save();
|
||||
}
|
||||
export interface QuickEvalContext {
|
||||
quickEvalPosition: messages.Position;
|
||||
quickEvalText: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the selection to be used for quick evaluation.
|
||||
*
|
||||
* If `range` is specified, then that range will be used. Otherwise, the current selection will be
|
||||
* used.
|
||||
*/
|
||||
export async function getQuickEvalContext(
|
||||
range: Range | undefined,
|
||||
): Promise<QuickEvalContext> {
|
||||
const editor = window.activeTextEditor;
|
||||
if (editor === undefined) {
|
||||
throw new Error("Can't run quick evaluation without an active editor.");
|
||||
}
|
||||
// For Quick Evaluation, the selected position comes from the active editor, but it's possible
|
||||
// that query itself was a different file. We need to validate the path of the file we're using
|
||||
// for the QuickEval selection in case it was different.
|
||||
validateQueryUri(editor.document.uri, true);
|
||||
const quickEvalPosition = await getSelectedPosition(editor, range);
|
||||
let quickEvalText: string;
|
||||
if (!editor.selection?.isEmpty) {
|
||||
quickEvalText = editor.document.getText(editor.selection).trim();
|
||||
} else {
|
||||
// capture the entire line if the user didn't select anything
|
||||
const line = editor.document.lineAt(editor.selection.active.line);
|
||||
quickEvalText = line.text.trim();
|
||||
}
|
||||
|
||||
let quickEvalPosition: messages.Position | undefined = undefined;
|
||||
let quickEvalText: string | undefined = undefined;
|
||||
if (quickEval) {
|
||||
if (editor === undefined) {
|
||||
throw new Error("Can't run quick evaluation without an active editor.");
|
||||
}
|
||||
if (editor.document.fileName !== queryPath) {
|
||||
// For Quick Evaluation we expect these to be the same.
|
||||
// Report an error if we end up in this (hopefully unlikely) situation.
|
||||
throw new Error(
|
||||
"The selected resource for quick evaluation should match the active editor.",
|
||||
);
|
||||
}
|
||||
quickEvalPosition = await getSelectedPosition(editor, range);
|
||||
if (!editor.selection?.isEmpty) {
|
||||
quickEvalText = editor.document.getText(editor.selection);
|
||||
} else {
|
||||
// capture the entire line if the user didn't select anything
|
||||
const line = editor.document.lineAt(editor.selection.active.line);
|
||||
quickEvalText = line.text.trim();
|
||||
}
|
||||
}
|
||||
return {
|
||||
quickEvalPosition,
|
||||
quickEvalText,
|
||||
};
|
||||
}
|
||||
|
||||
return { queryPath, quickEvalPosition, quickEvalText };
|
||||
/**
|
||||
* Information about which query will be to be run, optionally including a QuickEval selection.
|
||||
*/
|
||||
export interface SelectedQuery {
|
||||
queryPath: string;
|
||||
quickEval?: QuickEvalContext;
|
||||
}
|
||||
|
||||
/** Gets the selected position within the given editor. */
|
||||
@@ -512,7 +502,7 @@ async function getSelectedPosition(
|
||||
* @returns true if we should save changes and false if we should continue without saving changes.
|
||||
* @throws UserCancellationException if we should abort whatever operation triggered this prompt
|
||||
*/
|
||||
async function promptUserToSaveChanges(
|
||||
export async function promptUserToSaveChanges(
|
||||
document: TextDocument,
|
||||
): Promise<boolean> {
|
||||
if (document.isDirty) {
|
||||
@@ -526,7 +516,9 @@ async function promptUserToSaveChanges(
|
||||
isCloseAffordance: false,
|
||||
};
|
||||
const cancelItem = { title: "Cancel", isCloseAffordance: true };
|
||||
const message = "Query file has unsaved changes. Save now?";
|
||||
const message = `Query file '${basename(
|
||||
document.uri.fsPath,
|
||||
)}' has unsaved changes. Save now?`;
|
||||
const chosenItem = await window.showInformationMessage(
|
||||
message,
|
||||
{ modal: true },
|
||||
@@ -595,7 +587,7 @@ export async function createInitialQueryInfo(
|
||||
selectedQuery: SelectedQuery,
|
||||
databaseInfo: DatabaseInfo,
|
||||
): Promise<InitialQueryInfo> {
|
||||
const isQuickEval = selectedQuery.quickEvalPosition !== undefined;
|
||||
const isQuickEval = selectedQuery.quickEval !== undefined;
|
||||
return {
|
||||
queryPath: selectedQuery.queryPath,
|
||||
isQuickEval,
|
||||
@@ -603,10 +595,10 @@ export async function createInitialQueryInfo(
|
||||
databaseInfo,
|
||||
id: `${basename(selectedQuery.queryPath)}-${nanoid()}`,
|
||||
start: new Date(),
|
||||
...(isQuickEval
|
||||
...(selectedQuery.quickEval !== undefined
|
||||
? {
|
||||
queryText: selectedQuery.quickEvalText!, // if this query is quick eval, it must have quick eval text
|
||||
quickEvalPosition: selectedQuery.quickEvalPosition,
|
||||
queryText: selectedQuery.quickEval.quickEvalText,
|
||||
quickEvalPosition: selectedQuery.quickEval.quickEvalPosition,
|
||||
}
|
||||
: {
|
||||
queryText: await readFile(selectedQuery.queryPath, "utf8"),
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { access } from "fs-extra";
|
||||
import { dirname, extname } from "path";
|
||||
import * as vscode from "vscode";
|
||||
import {
|
||||
@@ -20,23 +19,11 @@ import {
|
||||
QLTestDirectory,
|
||||
QLTestDiscovery,
|
||||
} from "./qltest-discovery";
|
||||
import {
|
||||
Event,
|
||||
EventEmitter,
|
||||
CancellationTokenSource,
|
||||
CancellationToken,
|
||||
} from "vscode";
|
||||
import { Event, EventEmitter, CancellationTokenSource } from "vscode";
|
||||
import { DisposableObject } from "./pure/disposable-object";
|
||||
import { CodeQLCliServer } from "./cli";
|
||||
import {
|
||||
getOnDiskWorkspaceFolders,
|
||||
showAndLogExceptionWithTelemetry,
|
||||
showAndLogWarningMessage,
|
||||
} from "./helpers";
|
||||
import { CodeQLCliServer, TestCompleted } from "./cli";
|
||||
import { testLogger } from "./common";
|
||||
import { DatabaseItem, DatabaseManager } from "./local-databases";
|
||||
import { asError, getErrorMessage } from "./pure/helpers-pure";
|
||||
import { redactableError } from "./pure/errors";
|
||||
import { TestRunner } from "./test-runner";
|
||||
|
||||
/**
|
||||
* Get the full path of the `.expected` file for the specified QL test.
|
||||
@@ -77,8 +64,8 @@ function getTestOutputFile(testPath: string, extension: string): string {
|
||||
export class QLTestAdapterFactory extends DisposableObject {
|
||||
constructor(
|
||||
testHub: TestHub,
|
||||
testRunner: TestRunner,
|
||||
cliServer: CodeQLCliServer,
|
||||
databaseManager: DatabaseManager,
|
||||
) {
|
||||
super();
|
||||
|
||||
@@ -87,7 +74,7 @@ export class QLTestAdapterFactory extends DisposableObject {
|
||||
new TestAdapterRegistrar(
|
||||
testHub,
|
||||
(workspaceFolder) =>
|
||||
new QLTestAdapter(workspaceFolder, cliServer, databaseManager),
|
||||
new QLTestAdapter(workspaceFolder, testRunner, cliServer),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -120,8 +107,8 @@ export class QLTestAdapter extends DisposableObject implements TestAdapter {
|
||||
|
||||
constructor(
|
||||
public readonly workspaceFolder: vscode.WorkspaceFolder,
|
||||
private readonly cliServer: CodeQLCliServer,
|
||||
private readonly databaseManager: DatabaseManager,
|
||||
private readonly testRunner: TestRunner,
|
||||
cliServer: CodeQLCliServer,
|
||||
) {
|
||||
super();
|
||||
|
||||
@@ -232,110 +219,14 @@ export class QLTestAdapter extends DisposableObject implements TestAdapter {
|
||||
tests,
|
||||
} as TestRunStartedEvent);
|
||||
|
||||
const currentDatabaseUri =
|
||||
this.databaseManager.currentDatabaseItem?.databaseUri;
|
||||
const databasesUnderTest: DatabaseItem[] = [];
|
||||
for (const database of this.databaseManager.databaseItems) {
|
||||
for (const test of tests) {
|
||||
if (await database.isAffectedByTest(test)) {
|
||||
databasesUnderTest.push(database);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await this.removeDatabasesBeforeTests(databasesUnderTest, token);
|
||||
try {
|
||||
await this.runTests(tests, token);
|
||||
} catch (e) {
|
||||
// CodeQL testing can throw exception even in normal scenarios. For example, if the test run
|
||||
// produces no output (which is normal), the testing command would throw an exception on
|
||||
// unexpected EOF during json parsing. So nothing needs to be done here - all the relevant
|
||||
// error information (if any) should have already been written to the test logger.
|
||||
}
|
||||
await this.reopenDatabasesAfterTests(
|
||||
databasesUnderTest,
|
||||
currentDatabaseUri,
|
||||
token,
|
||||
await this.testRunner.run(tests, testLogger, token, (event) =>
|
||||
this.processTestEvent(event),
|
||||
);
|
||||
|
||||
this._testStates.fire({ type: "finished" } as TestRunFinishedEvent);
|
||||
this.clearTask();
|
||||
}
|
||||
|
||||
private async removeDatabasesBeforeTests(
|
||||
databasesUnderTest: DatabaseItem[],
|
||||
token: vscode.CancellationToken,
|
||||
): Promise<void> {
|
||||
for (const database of databasesUnderTest) {
|
||||
try {
|
||||
await this.databaseManager.removeDatabaseItem(
|
||||
(_) => {
|
||||
/* no progress reporting */
|
||||
},
|
||||
token,
|
||||
database,
|
||||
);
|
||||
} catch (e) {
|
||||
// This method is invoked from Test Explorer UI, and testing indicates that Test
|
||||
// Explorer UI swallows any thrown exception without reporting it to the user.
|
||||
// So we need to display the error message ourselves and then rethrow.
|
||||
void showAndLogExceptionWithTelemetry(
|
||||
redactableError(asError(e))`Cannot remove database ${
|
||||
database.name
|
||||
}: ${getErrorMessage(e)}`,
|
||||
);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async reopenDatabasesAfterTests(
|
||||
databasesUnderTest: DatabaseItem[],
|
||||
currentDatabaseUri: vscode.Uri | undefined,
|
||||
token: vscode.CancellationToken,
|
||||
): Promise<void> {
|
||||
for (const closedDatabase of databasesUnderTest) {
|
||||
const uri = closedDatabase.databaseUri;
|
||||
if (await this.isFileAccessible(uri)) {
|
||||
try {
|
||||
const reopenedDatabase = await this.databaseManager.openDatabase(
|
||||
(_) => {
|
||||
/* no progress reporting */
|
||||
},
|
||||
token,
|
||||
uri,
|
||||
);
|
||||
await this.databaseManager.renameDatabaseItem(
|
||||
reopenedDatabase,
|
||||
closedDatabase.name,
|
||||
);
|
||||
if (currentDatabaseUri?.toString() === uri.toString()) {
|
||||
await this.databaseManager.setCurrentDatabaseItem(
|
||||
reopenedDatabase,
|
||||
true,
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
// This method is invoked from Test Explorer UI, and testing indicates that Test
|
||||
// Explorer UI swallows any thrown exception without reporting it to the user.
|
||||
// So we need to display the error message ourselves and then rethrow.
|
||||
void showAndLogWarningMessage(`Cannot reopen database ${uri}: ${e}`);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async isFileAccessible(uri: vscode.Uri): Promise<boolean> {
|
||||
try {
|
||||
await access(uri.fsPath);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private clearTask(): void {
|
||||
if (this.runningTask !== undefined) {
|
||||
const runningTask = this.runningTask;
|
||||
@@ -352,49 +243,42 @@ export class QLTestAdapter extends DisposableObject implements TestAdapter {
|
||||
}
|
||||
}
|
||||
|
||||
private async runTests(
|
||||
tests: string[],
|
||||
cancellationToken: CancellationToken,
|
||||
): Promise<void> {
|
||||
const workspacePaths = getOnDiskWorkspaceFolders();
|
||||
for await (const event of this.cliServer.runTests(tests, workspacePaths, {
|
||||
cancellationToken,
|
||||
logger: testLogger,
|
||||
})) {
|
||||
const state = event.pass
|
||||
? "passed"
|
||||
: event.messages?.length
|
||||
? "errored"
|
||||
: "failed";
|
||||
let message: string | undefined;
|
||||
if (event.failureDescription || event.diff?.length) {
|
||||
message =
|
||||
event.failureStage === "RESULT"
|
||||
? [
|
||||
"",
|
||||
`${state}: ${event.test}`,
|
||||
event.failureDescription || event.diff?.join("\n"),
|
||||
"",
|
||||
].join("\n")
|
||||
: [
|
||||
"",
|
||||
`${event.failureStage?.toLowerCase()} error: ${event.test}`,
|
||||
event.failureDescription ||
|
||||
`${event.messages[0].severity}: ${event.messages[0].message}`,
|
||||
"",
|
||||
].join("\n");
|
||||
void testLogger.log(message);
|
||||
}
|
||||
this._testStates.fire({
|
||||
type: "test",
|
||||
state,
|
||||
test: event.test,
|
||||
message,
|
||||
decorations: event.messages?.map((msg) => ({
|
||||
line: msg.position.line,
|
||||
message: msg.message,
|
||||
})),
|
||||
});
|
||||
private async processTestEvent(event: TestCompleted): Promise<void> {
|
||||
const state = event.pass
|
||||
? "passed"
|
||||
: event.messages?.length
|
||||
? "errored"
|
||||
: "failed";
|
||||
let message: string | undefined;
|
||||
if (event.failureDescription || event.diff?.length) {
|
||||
message =
|
||||
event.failureStage === "RESULT"
|
||||
? [
|
||||
"",
|
||||
`${state}: ${event.test}`,
|
||||
event.failureDescription || event.diff?.join("\n"),
|
||||
"",
|
||||
].join("\n")
|
||||
: [
|
||||
"",
|
||||
`${event.failureStage?.toLowerCase() ?? "unknown stage"} error: ${
|
||||
event.test
|
||||
}`,
|
||||
event.failureDescription ||
|
||||
`${event.messages[0].severity}: ${event.messages[0].message}`,
|
||||
"",
|
||||
].join("\n");
|
||||
void testLogger.log(message);
|
||||
}
|
||||
this._testStates.fire({
|
||||
type: "test",
|
||||
state,
|
||||
test: event.test,
|
||||
message,
|
||||
decorations: event.messages?.map((msg) => ({
|
||||
line: msg.position.line,
|
||||
message: msg.message,
|
||||
})),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
74
extensions/ql-vscode/src/test-manager-base.ts
Normal file
74
extensions/ql-vscode/src/test-manager-base.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import { copy, createFile, lstat, pathExists } from "fs-extra";
|
||||
import { TestUICommands } from "./common/commands";
|
||||
import { DisposableObject } from "./pure/disposable-object";
|
||||
import { getActualFile, getExpectedFile } from "./test-adapter";
|
||||
import { TestItem, TextDocumentShowOptions, Uri, window } from "vscode";
|
||||
import { basename } from "path";
|
||||
import { App } from "./common/app";
|
||||
import { TestTreeNode } from "./test-tree-node";
|
||||
|
||||
export type TestNode = TestTreeNode | TestItem;
|
||||
|
||||
/**
|
||||
* Base class for both the legacy and new test services. Implements commands that are common to
|
||||
* both.
|
||||
*/
|
||||
export abstract class TestManagerBase extends DisposableObject {
|
||||
protected constructor(private readonly app: App) {
|
||||
super();
|
||||
}
|
||||
|
||||
public getCommands(): TestUICommands {
|
||||
return {
|
||||
"codeQLTests.showOutputDifferences":
|
||||
this.showOutputDifferences.bind(this),
|
||||
"codeQLTests.acceptOutput": this.acceptOutput.bind(this),
|
||||
"codeQLTests.acceptOutputContextTestItem": this.acceptOutput.bind(this),
|
||||
};
|
||||
}
|
||||
|
||||
/** Override to compute the path of the test file from the selected node. */
|
||||
protected abstract getTestPath(node: TestNode): string;
|
||||
|
||||
private async acceptOutput(node: TestNode): Promise<void> {
|
||||
const testPath = this.getTestPath(node);
|
||||
const stat = await lstat(testPath);
|
||||
if (stat.isFile()) {
|
||||
const expectedPath = getExpectedFile(testPath);
|
||||
const actualPath = getActualFile(testPath);
|
||||
await copy(actualPath, expectedPath, { overwrite: true });
|
||||
}
|
||||
}
|
||||
|
||||
private async showOutputDifferences(node: TestNode): Promise<void> {
|
||||
const testId = this.getTestPath(node);
|
||||
const stat = await lstat(testId);
|
||||
if (stat.isFile()) {
|
||||
const expectedPath = getExpectedFile(testId);
|
||||
const expectedUri = Uri.file(expectedPath);
|
||||
const actualPath = getActualFile(testId);
|
||||
const options: TextDocumentShowOptions = {
|
||||
preserveFocus: true,
|
||||
preview: true,
|
||||
};
|
||||
|
||||
if (!(await pathExists(expectedPath))) {
|
||||
// Just create a new file.
|
||||
await createFile(expectedPath);
|
||||
}
|
||||
|
||||
if (await pathExists(actualPath)) {
|
||||
const actualUri = Uri.file(actualPath);
|
||||
await this.app.commands.execute(
|
||||
"vscode.diff",
|
||||
expectedUri,
|
||||
actualUri,
|
||||
`Expected vs. Actual for ${basename(testId)}`,
|
||||
options,
|
||||
);
|
||||
} else {
|
||||
await window.showTextDocument(expectedUri, options);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
373
extensions/ql-vscode/src/test-manager.ts
Normal file
373
extensions/ql-vscode/src/test-manager.ts
Normal file
@@ -0,0 +1,373 @@
|
||||
import { readFile } from "fs-extra";
|
||||
import {
|
||||
CancellationToken,
|
||||
Location,
|
||||
Range,
|
||||
TestController,
|
||||
TestItem,
|
||||
TestMessage,
|
||||
TestRun,
|
||||
TestRunProfileKind,
|
||||
TestRunRequest,
|
||||
Uri,
|
||||
WorkspaceFolder,
|
||||
WorkspaceFoldersChangeEvent,
|
||||
tests,
|
||||
workspace,
|
||||
} from "vscode";
|
||||
import { DisposableObject } from "./pure/disposable-object";
|
||||
import {
|
||||
QLTestDirectory,
|
||||
QLTestDiscovery,
|
||||
QLTestFile,
|
||||
QLTestNode,
|
||||
} from "./qltest-discovery";
|
||||
import { CodeQLCliServer } from "./cli";
|
||||
import { getErrorMessage } from "./pure/helpers-pure";
|
||||
import { BaseLogger, LogOptions } from "./common";
|
||||
import { TestRunner } from "./test-runner";
|
||||
import { TestManagerBase } from "./test-manager-base";
|
||||
import { App } from "./common/app";
|
||||
|
||||
/**
|
||||
* Returns the complete text content of the specified file. If there is an error reading the file,
|
||||
* an error message is added to `testMessages` and this function returns undefined.
|
||||
*/
|
||||
async function tryReadFileContents(
|
||||
path: string,
|
||||
testMessages: TestMessage[],
|
||||
): Promise<string | undefined> {
|
||||
try {
|
||||
return await readFile(path, { encoding: "utf-8" });
|
||||
} catch (e) {
|
||||
testMessages.push(
|
||||
new TestMessage(
|
||||
`Error reading from file '${path}': ${getErrorMessage(e)}`,
|
||||
),
|
||||
);
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function forEachTest(testItem: TestItem, op: (test: TestItem) => void): void {
|
||||
if (testItem.children.size > 0) {
|
||||
// This is a directory, so recurse into the children.
|
||||
for (const [, child] of testItem.children) {
|
||||
forEachTest(child, op);
|
||||
}
|
||||
} else {
|
||||
// This is a leaf node, so it's a test.
|
||||
op(testItem);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Implementation of `BaseLogger` that logs to the output of a `TestRun`.
|
||||
*/
|
||||
class TestRunLogger implements BaseLogger {
|
||||
public constructor(private readonly testRun: TestRun) {}
|
||||
|
||||
public async log(message: string, options?: LogOptions): Promise<void> {
|
||||
// "\r\n" because that's what the test terminal wants.
|
||||
const lineEnding = options?.trailingNewline === false ? "" : "\r\n";
|
||||
this.testRun.appendOutput(message + lineEnding);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles test discovery for a specific workspace folder, and reports back to `TestManager`.
|
||||
*/
|
||||
class WorkspaceFolderHandler extends DisposableObject {
|
||||
private readonly testDiscovery: QLTestDiscovery;
|
||||
|
||||
public constructor(
|
||||
private readonly workspaceFolder: WorkspaceFolder,
|
||||
private readonly testUI: TestManager,
|
||||
cliServer: CodeQLCliServer,
|
||||
) {
|
||||
super();
|
||||
|
||||
this.testDiscovery = new QLTestDiscovery(workspaceFolder, cliServer);
|
||||
this.push(
|
||||
this.testDiscovery.onDidChangeTests(this.handleDidChangeTests, this),
|
||||
);
|
||||
this.testDiscovery.refresh();
|
||||
}
|
||||
|
||||
private handleDidChangeTests(): void {
|
||||
const testDirectory = this.testDiscovery.testDirectory;
|
||||
|
||||
this.testUI.updateTestsForWorkspaceFolder(
|
||||
this.workspaceFolder,
|
||||
testDirectory,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Service that populates the VS Code "Test Explorer" panel for CodeQL, and handles running and
|
||||
* debugging of tests.
|
||||
*/
|
||||
export class TestManager extends TestManagerBase {
|
||||
/**
|
||||
* Maps from each workspace folder being tracked to the `WorkspaceFolderHandler` responsible for
|
||||
* tracking it.
|
||||
*/
|
||||
private readonly workspaceFolderHandlers = new Map<
|
||||
WorkspaceFolder,
|
||||
WorkspaceFolderHandler
|
||||
>();
|
||||
|
||||
public constructor(
|
||||
app: App,
|
||||
private readonly testRunner: TestRunner,
|
||||
private readonly cliServer: CodeQLCliServer,
|
||||
// Having this as a parameter with a default value makes passing in a mock easier.
|
||||
private readonly testController: TestController = tests.createTestController(
|
||||
"codeql",
|
||||
"CodeQL Tests",
|
||||
),
|
||||
) {
|
||||
super(app);
|
||||
|
||||
this.testController.createRunProfile(
|
||||
"Run",
|
||||
TestRunProfileKind.Run,
|
||||
this.run.bind(this),
|
||||
);
|
||||
|
||||
// Start by tracking whatever folders are currently in the workspace.
|
||||
this.startTrackingWorkspaceFolders(workspace.workspaceFolders ?? []);
|
||||
|
||||
// Listen for changes to the set of workspace folders.
|
||||
workspace.onDidChangeWorkspaceFolders(
|
||||
this.handleDidChangeWorkspaceFolders,
|
||||
this,
|
||||
);
|
||||
}
|
||||
|
||||
public dispose(): void {
|
||||
this.workspaceFolderHandlers.clear(); // These will be disposed in the `super.dispose()` call.
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
protected getTestPath(node: TestItem): string {
|
||||
if (node.uri === undefined || node.uri.scheme !== "file") {
|
||||
throw new Error("Selected test is not a CodeQL test.");
|
||||
}
|
||||
return node.uri.fsPath;
|
||||
}
|
||||
|
||||
/** Start tracking tests in the specified workspace folders. */
|
||||
private startTrackingWorkspaceFolders(
|
||||
workspaceFolders: readonly WorkspaceFolder[],
|
||||
): void {
|
||||
for (const workspaceFolder of workspaceFolders) {
|
||||
const workspaceFolderHandler = new WorkspaceFolderHandler(
|
||||
workspaceFolder,
|
||||
this,
|
||||
this.cliServer,
|
||||
);
|
||||
this.track(workspaceFolderHandler);
|
||||
this.workspaceFolderHandlers.set(workspaceFolder, workspaceFolderHandler);
|
||||
}
|
||||
}
|
||||
|
||||
/** Stop tracking tests in the specified workspace folders. */
|
||||
private stopTrackingWorkspaceFolders(
|
||||
workspaceFolders: readonly WorkspaceFolder[],
|
||||
): void {
|
||||
for (const workspaceFolder of workspaceFolders) {
|
||||
const workspaceFolderHandler =
|
||||
this.workspaceFolderHandlers.get(workspaceFolder);
|
||||
if (workspaceFolderHandler !== undefined) {
|
||||
// Delete the root item for this workspace folder, if any.
|
||||
this.testController.items.delete(workspaceFolder.uri.toString());
|
||||
this.disposeAndStopTracking(workspaceFolderHandler);
|
||||
this.workspaceFolderHandlers.delete(workspaceFolder);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private handleDidChangeWorkspaceFolders(
|
||||
e: WorkspaceFoldersChangeEvent,
|
||||
): void {
|
||||
this.startTrackingWorkspaceFolders(e.added);
|
||||
this.stopTrackingWorkspaceFolders(e.removed);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the test controller when we discover changes to the tests in the workspace folder.
|
||||
*/
|
||||
public updateTestsForWorkspaceFolder(
|
||||
workspaceFolder: WorkspaceFolder,
|
||||
testDirectory: QLTestDirectory | undefined,
|
||||
): void {
|
||||
if (testDirectory !== undefined) {
|
||||
// Adding an item with the same ID as an existing item will replace it, which is exactly what
|
||||
// we want.
|
||||
// Test discovery returns a root `QLTestDirectory` representing the workspace folder itself,
|
||||
// named after the `WorkspaceFolder` object's `name` property. We can map this directly to a
|
||||
// `TestItem`.
|
||||
this.testController.items.add(
|
||||
this.createTestItemTree(testDirectory, true),
|
||||
);
|
||||
} else {
|
||||
// No tests, so delete any existing item.
|
||||
this.testController.items.delete(workspaceFolder.uri.toString());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a tree of `TestItem`s from the root `QlTestNode` provided by test discovery.
|
||||
*/
|
||||
private createTestItemTree(node: QLTestNode, isRoot: boolean): TestItem {
|
||||
// Prefix the ID to identify it as a directory or a test
|
||||
const itemType = node instanceof QLTestDirectory ? "dir" : "test";
|
||||
const testItem = this.testController.createTestItem(
|
||||
// For the root of a workspace folder, use the full path as the ID. Otherwise, use the node's
|
||||
// name as the ID, since it's shorter but still unique.
|
||||
`${itemType} ${isRoot ? node.path : node.name}`,
|
||||
node.name,
|
||||
Uri.file(node.path),
|
||||
);
|
||||
|
||||
for (const childNode of node.children) {
|
||||
const childItem = this.createTestItemTree(childNode, false);
|
||||
if (childNode instanceof QLTestFile) {
|
||||
childItem.range = new Range(0, 0, 0, 0);
|
||||
}
|
||||
testItem.children.add(childItem);
|
||||
}
|
||||
|
||||
return testItem;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the tests specified by the `TestRunRequest` parameter.
|
||||
*
|
||||
* Public because this is used in unit tests.
|
||||
*/
|
||||
public async run(
|
||||
request: TestRunRequest,
|
||||
token: CancellationToken,
|
||||
): Promise<void> {
|
||||
const testsToRun = this.computeTestsToRun(request);
|
||||
const testRun = this.testController.createTestRun(request, undefined, true);
|
||||
try {
|
||||
const tests: string[] = [];
|
||||
testsToRun.forEach((testItem, testPath) => {
|
||||
testRun.enqueued(testItem);
|
||||
tests.push(testPath);
|
||||
});
|
||||
|
||||
const logger = new TestRunLogger(testRun);
|
||||
|
||||
await this.testRunner.run(tests, logger, token, async (event) => {
|
||||
// Pass the test path from the event through `Uri` and back via `fsPath` so that it matches
|
||||
// the canonicalization of the URI that we used to create the `TestItem`.
|
||||
const testItem = testsToRun.get(Uri.file(event.test).fsPath);
|
||||
if (testItem === undefined) {
|
||||
throw new Error(
|
||||
`Unexpected result from unknown test '${event.test}'.`,
|
||||
);
|
||||
}
|
||||
|
||||
const duration = event.compilationMs + event.evaluationMs;
|
||||
if (event.pass) {
|
||||
testRun.passed(testItem, duration);
|
||||
} else {
|
||||
// Construct a list of `TestMessage`s to report for the failure.
|
||||
const testMessages: TestMessage[] = [];
|
||||
if (event.failureDescription !== undefined) {
|
||||
testMessages.push(new TestMessage(event.failureDescription));
|
||||
}
|
||||
if (event.diff?.length && event.actual !== undefined) {
|
||||
// Actual results differ from expected results. Read both sets of results and create a
|
||||
// diff to put in the message.
|
||||
const expected = await tryReadFileContents(
|
||||
event.expected,
|
||||
testMessages,
|
||||
);
|
||||
const actual = await tryReadFileContents(
|
||||
event.actual,
|
||||
testMessages,
|
||||
);
|
||||
if (expected !== undefined && actual !== undefined) {
|
||||
testMessages.push(
|
||||
TestMessage.diff(
|
||||
"Actual output differs from expected",
|
||||
expected,
|
||||
actual,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
if (event.messages?.length > 0) {
|
||||
// The test didn't make it far enough to produce results. Transform any error messages
|
||||
// into `TestMessage`s and report the test as "errored".
|
||||
const testMessages = event.messages.map((m) => {
|
||||
const location = new Location(
|
||||
Uri.file(m.position.fileName),
|
||||
new Range(
|
||||
m.position.line - 1,
|
||||
m.position.column - 1,
|
||||
m.position.endLine - 1,
|
||||
m.position.endColumn - 1,
|
||||
),
|
||||
);
|
||||
const testMessage = new TestMessage(m.message);
|
||||
testMessage.location = location;
|
||||
return testMessage;
|
||||
});
|
||||
testRun.errored(testItem, testMessages, duration);
|
||||
} else {
|
||||
// Results didn't match expectations. Report the test as "failed".
|
||||
if (testMessages.length === 0) {
|
||||
// If we managed to get here without creating any `TestMessage`s, create a default one
|
||||
// here. Any failed test needs at least one message.
|
||||
testMessages.push(new TestMessage("Test failed"));
|
||||
}
|
||||
testRun.failed(testItem, testMessages, duration);
|
||||
}
|
||||
}
|
||||
});
|
||||
} finally {
|
||||
testRun.end();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Computes the set of tests to run as specified in the `TestRunRequest` object.
|
||||
*/
|
||||
private computeTestsToRun(request: TestRunRequest): Map<string, TestItem> {
|
||||
const testsToRun = new Map<string, TestItem>();
|
||||
if (request.include !== undefined) {
|
||||
// Include these tests, recursively expanding test directories into their list of contained
|
||||
// tests.
|
||||
for (const includedTestItem of request.include) {
|
||||
forEachTest(includedTestItem, (testItem) =>
|
||||
testsToRun.set(testItem.uri!.fsPath, testItem),
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// Include all of the tests.
|
||||
for (const [, includedTestItem] of this.testController.items) {
|
||||
forEachTest(includedTestItem, (testItem) =>
|
||||
testsToRun.set(testItem.uri!.fsPath, testItem),
|
||||
);
|
||||
}
|
||||
}
|
||||
if (request.exclude !== undefined) {
|
||||
// Exclude the specified tests from the set we've computed so far, again recursively expanding
|
||||
// test directories into their list of contained tests.
|
||||
for (const excludedTestItem of request.exclude) {
|
||||
forEachTest(excludedTestItem, (testItem) =>
|
||||
testsToRun.delete(testItem.uri!.fsPath),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return testsToRun;
|
||||
}
|
||||
}
|
||||
136
extensions/ql-vscode/src/test-runner.ts
Normal file
136
extensions/ql-vscode/src/test-runner.ts
Normal file
@@ -0,0 +1,136 @@
|
||||
import { CancellationToken, Uri } from "vscode";
|
||||
import { CodeQLCliServer, TestCompleted } from "./cli";
|
||||
import { DatabaseItem, DatabaseManager } from "./local-databases";
|
||||
import {
|
||||
getOnDiskWorkspaceFolders,
|
||||
showAndLogExceptionWithTelemetry,
|
||||
showAndLogWarningMessage,
|
||||
} from "./helpers";
|
||||
import { asError, getErrorMessage } from "./pure/helpers-pure";
|
||||
import { redactableError } from "./pure/errors";
|
||||
import { access } from "fs-extra";
|
||||
import { BaseLogger } from "./common";
|
||||
import { DisposableObject } from "./pure/disposable-object";
|
||||
|
||||
async function isFileAccessible(uri: Uri): Promise<boolean> {
|
||||
try {
|
||||
await access(uri.fsPath);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export class TestRunner extends DisposableObject {
|
||||
public constructor(
|
||||
private readonly databaseManager: DatabaseManager,
|
||||
private readonly cliServer: CodeQLCliServer,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
public async run(
|
||||
tests: string[],
|
||||
logger: BaseLogger,
|
||||
token: CancellationToken,
|
||||
eventHandler: (event: TestCompleted) => Promise<void>,
|
||||
): Promise<void> {
|
||||
const currentDatabaseUri =
|
||||
this.databaseManager.currentDatabaseItem?.databaseUri;
|
||||
const databasesUnderTest: DatabaseItem[] = [];
|
||||
for (const database of this.databaseManager.databaseItems) {
|
||||
for (const test of tests) {
|
||||
if (await database.isAffectedByTest(test)) {
|
||||
databasesUnderTest.push(database);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await this.removeDatabasesBeforeTests(databasesUnderTest, token);
|
||||
try {
|
||||
const workspacePaths = getOnDiskWorkspaceFolders();
|
||||
for await (const event of this.cliServer.runTests(tests, workspacePaths, {
|
||||
cancellationToken: token,
|
||||
logger,
|
||||
})) {
|
||||
await eventHandler(event);
|
||||
}
|
||||
} catch (e) {
|
||||
// CodeQL testing can throw exception even in normal scenarios. For example, if the test run
|
||||
// produces no output (which is normal), the testing command would throw an exception on
|
||||
// unexpected EOF during json parsing. So nothing needs to be done here - all the relevant
|
||||
// error information (if any) should have already been written to the test logger.
|
||||
} finally {
|
||||
await this.reopenDatabasesAfterTests(
|
||||
databasesUnderTest,
|
||||
currentDatabaseUri,
|
||||
token,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async removeDatabasesBeforeTests(
|
||||
databasesUnderTest: DatabaseItem[],
|
||||
token: CancellationToken,
|
||||
): Promise<void> {
|
||||
for (const database of databasesUnderTest) {
|
||||
try {
|
||||
await this.databaseManager.removeDatabaseItem(
|
||||
(_) => {
|
||||
/* no progress reporting */
|
||||
},
|
||||
token,
|
||||
database,
|
||||
);
|
||||
} catch (e) {
|
||||
// This method is invoked from Test Explorer UI, and testing indicates that Test
|
||||
// Explorer UI swallows any thrown exception without reporting it to the user.
|
||||
// So we need to display the error message ourselves and then rethrow.
|
||||
void showAndLogExceptionWithTelemetry(
|
||||
redactableError(asError(e))`Cannot remove database ${
|
||||
database.name
|
||||
}: ${getErrorMessage(e)}`,
|
||||
);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async reopenDatabasesAfterTests(
|
||||
databasesUnderTest: DatabaseItem[],
|
||||
currentDatabaseUri: Uri | undefined,
|
||||
token: CancellationToken,
|
||||
): Promise<void> {
|
||||
for (const closedDatabase of databasesUnderTest) {
|
||||
const uri = closedDatabase.databaseUri;
|
||||
if (await isFileAccessible(uri)) {
|
||||
try {
|
||||
const reopenedDatabase = await this.databaseManager.openDatabase(
|
||||
(_) => {
|
||||
/* no progress reporting */
|
||||
},
|
||||
token,
|
||||
uri,
|
||||
);
|
||||
await this.databaseManager.renameDatabaseItem(
|
||||
reopenedDatabase,
|
||||
closedDatabase.name,
|
||||
);
|
||||
if (currentDatabaseUri?.toString() === uri.toString()) {
|
||||
await this.databaseManager.setCurrentDatabaseItem(
|
||||
reopenedDatabase,
|
||||
true,
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
// This method is invoked from Test Explorer UI, and testing indicates that Test
|
||||
// Explorer UI swallows any thrown exception without reporting it to the user.
|
||||
// So we need to display the error message ourselves and then rethrow.
|
||||
void showAndLogWarningMessage(`Cannot reopen database ${uri}: ${e}`);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,3 @@
|
||||
import { lstat, copy, pathExists, createFile } from "fs-extra";
|
||||
import { basename } from "path";
|
||||
import { Uri, TextDocumentShowOptions, window } from "vscode";
|
||||
import {
|
||||
TestHub,
|
||||
TestController,
|
||||
@@ -10,13 +7,11 @@ import {
|
||||
TestEvent,
|
||||
TestSuiteEvent,
|
||||
} from "vscode-test-adapter-api";
|
||||
|
||||
import { showAndLogWarningMessage } from "./helpers";
|
||||
import { TestTreeNode } from "./test-tree-node";
|
||||
import { DisposableObject } from "./pure/disposable-object";
|
||||
import { QLTestAdapter, getExpectedFile, getActualFile } from "./test-adapter";
|
||||
import { TestUICommands } from "./common/commands";
|
||||
import { QLTestAdapter } from "./test-adapter";
|
||||
import { App } from "./common/app";
|
||||
import { TestManagerBase } from "./test-manager-base";
|
||||
|
||||
type VSCodeTestEvent =
|
||||
| TestRunStartedEvent
|
||||
@@ -42,23 +37,15 @@ class QLTestListener extends DisposableObject {
|
||||
/**
|
||||
* Service that implements all UI and commands for QL tests.
|
||||
*/
|
||||
export class TestUIService extends DisposableObject implements TestController {
|
||||
export class TestUIService extends TestManagerBase implements TestController {
|
||||
private readonly listeners: Map<TestAdapter, QLTestListener> = new Map();
|
||||
|
||||
constructor(private readonly app: App, private readonly testHub: TestHub) {
|
||||
super();
|
||||
public constructor(app: App, private readonly testHub: TestHub) {
|
||||
super(app);
|
||||
|
||||
testHub.registerTestController(this);
|
||||
}
|
||||
|
||||
public getCommands(): TestUICommands {
|
||||
return {
|
||||
"codeQLTests.showOutputDifferences":
|
||||
this.showOutputDifferences.bind(this),
|
||||
"codeQLTests.acceptOutput": this.acceptOutput.bind(this),
|
||||
};
|
||||
}
|
||||
|
||||
public dispose(): void {
|
||||
this.testHub.unregisterTestController(this);
|
||||
|
||||
@@ -75,47 +62,7 @@ export class TestUIService extends DisposableObject implements TestController {
|
||||
}
|
||||
}
|
||||
|
||||
private async acceptOutput(node: TestTreeNode): Promise<void> {
|
||||
const testId = node.info.id;
|
||||
const stat = await lstat(testId);
|
||||
if (stat.isFile()) {
|
||||
const expectedPath = getExpectedFile(testId);
|
||||
const actualPath = getActualFile(testId);
|
||||
await copy(actualPath, expectedPath, { overwrite: true });
|
||||
}
|
||||
}
|
||||
|
||||
private async showOutputDifferences(node: TestTreeNode): Promise<void> {
|
||||
const testId = node.info.id;
|
||||
const stat = await lstat(testId);
|
||||
if (stat.isFile()) {
|
||||
const expectedPath = getExpectedFile(testId);
|
||||
const expectedUri = Uri.file(expectedPath);
|
||||
const actualPath = getActualFile(testId);
|
||||
const options: TextDocumentShowOptions = {
|
||||
preserveFocus: true,
|
||||
preview: true,
|
||||
};
|
||||
|
||||
if (!(await pathExists(expectedPath))) {
|
||||
void showAndLogWarningMessage(
|
||||
`'${basename(expectedPath)}' does not exist. Creating an empty file.`,
|
||||
);
|
||||
await createFile(expectedPath);
|
||||
}
|
||||
|
||||
if (await pathExists(actualPath)) {
|
||||
const actualUri = Uri.file(actualPath);
|
||||
await this.app.commands.execute(
|
||||
"vscode.diff",
|
||||
expectedUri,
|
||||
actualUri,
|
||||
`Expected vs. Actual for ${basename(testId)}`,
|
||||
options,
|
||||
);
|
||||
} else {
|
||||
await window.showTextDocument(expectedUri, options);
|
||||
}
|
||||
}
|
||||
protected getTestPath(node: TestTreeNode): string {
|
||||
return node.info.id;
|
||||
}
|
||||
}
|
||||
|
||||
3
extensions/ql-vscode/test/data/.gitignore
vendored
3
extensions/ql-vscode/test/data/.gitignore
vendored
@@ -1 +1,2 @@
|
||||
.vscode
|
||||
.vscode/**
|
||||
!.vscode/launch.json
|
||||
|
||||
11
extensions/ql-vscode/test/data/.vscode/launch.json
vendored
Normal file
11
extensions/ql-vscode/test/data/.vscode/launch.json
vendored
Normal file
@@ -0,0 +1,11 @@
|
||||
// A launch configuration that compiles the extension and then opens it inside a new window
|
||||
{
|
||||
"version": "0.2.0",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "simple-query",
|
||||
"type": "codeql",
|
||||
"request": "launch"
|
||||
}
|
||||
]
|
||||
}
|
||||
0
extensions/ql-vscode/test/data/textfile.txt
Normal file
0
extensions/ql-vscode/test/data/textfile.txt
Normal file
@@ -7,10 +7,15 @@ type CmdDecl = {
|
||||
title?: string;
|
||||
};
|
||||
|
||||
type DebuggerDecl = {
|
||||
variables?: Record<string, string>;
|
||||
};
|
||||
|
||||
describe("commands declared in package.json", () => {
|
||||
const manifest = readJsonSync(join(__dirname, "../../package.json"));
|
||||
const commands = manifest.contributes.commands;
|
||||
const menus = manifest.contributes.menus;
|
||||
const debuggers = manifest.contributes.debuggers;
|
||||
|
||||
const disabledInPalette: Set<string> = new Set<string>();
|
||||
|
||||
@@ -60,6 +65,15 @@ describe("commands declared in package.json", () => {
|
||||
contribContextMenuCmds.add(command);
|
||||
});
|
||||
|
||||
debuggers.forEach((debuggerDecl: DebuggerDecl) => {
|
||||
if (debuggerDecl.variables !== undefined) {
|
||||
for (const command of Object.values(debuggerDecl.variables)) {
|
||||
// Commands used as debug configuration variables need not be enabled in the command palette.
|
||||
paletteCmds.delete(command);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
menus.commandPalette.forEach((commandDecl: CmdDecl) => {
|
||||
if (commandDecl.when === "false")
|
||||
disabledInPalette.add(commandDecl.command);
|
||||
@@ -85,6 +99,9 @@ describe("commands declared in package.json", () => {
|
||||
it("should have the right commands accessible from the command palette", () => {
|
||||
paletteCmds.forEach((command) => {
|
||||
// command ${command} should be enabled in the command palette
|
||||
if (disabledInPalette.has(command) !== false) {
|
||||
expect(command).toBe("enabled");
|
||||
}
|
||||
expect(disabledInPalette.has(command)).toBe(false);
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
newtype TNumber = MkNumber(int n) {
|
||||
n in [0..20]
|
||||
}
|
||||
|
||||
abstract class InterestingNumber extends TNumber
|
||||
{
|
||||
int value;
|
||||
|
||||
InterestingNumber() {
|
||||
this = MkNumber(value)
|
||||
}
|
||||
|
||||
string toString() {
|
||||
result = value.toString()
|
||||
}
|
||||
|
||||
final int getValue() {
|
||||
result = value
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import QuickEvalLib
|
||||
|
||||
class PrimeNumber extends InterestingNumber {
|
||||
PrimeNumber() {
|
||||
exists(int n | this = MkNumber(n) |
|
||||
n in [
|
||||
2,
|
||||
3,
|
||||
5,
|
||||
7,
|
||||
11,
|
||||
13,
|
||||
17,
|
||||
19
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
from InterestingNumber n
|
||||
select n.toString()
|
||||
@@ -0,0 +1,433 @@
|
||||
import {
|
||||
DebugAdapterTracker,
|
||||
DebugAdapterTrackerFactory,
|
||||
DebugSession,
|
||||
ProviderResult,
|
||||
Uri,
|
||||
debug,
|
||||
workspace,
|
||||
} from "vscode";
|
||||
import * as CodeQLProtocol from "../../../../src/debugger/debug-protocol";
|
||||
import { DisposableObject } from "../../../../src/pure/disposable-object";
|
||||
import { QueryResultType } from "../../../../src/pure/legacy-messages";
|
||||
import { CoreCompletedQuery } from "../../../../src/queryRunner";
|
||||
import { QueryOutputDir } from "../../../../src/run-queries-shared";
|
||||
import {
|
||||
QLDebugArgs,
|
||||
QLDebugConfiguration,
|
||||
} from "../../../../src/debugger/debug-configuration";
|
||||
import { join } from "path";
|
||||
import { writeFile } from "fs-extra";
|
||||
import { expect } from "@jest/globals";
|
||||
import { AppCommandManager } from "../../../../src/common/commands";
|
||||
import { getOnDiskWorkspaceFolders } from "../../../../src/helpers";
|
||||
|
||||
type Resolver<T> = (value: T) => void;
|
||||
|
||||
/**
|
||||
* Listens for Debug Adapter Protocol messages from a particular debug session, and reports the
|
||||
* interesting events back to the `DebugController`.
|
||||
*/
|
||||
class Tracker implements DebugAdapterTracker {
|
||||
private database: string | undefined;
|
||||
private queryPath: string | undefined;
|
||||
private started: CodeQLProtocol.EvaluationStartedEvent["body"] | undefined =
|
||||
undefined;
|
||||
private completed:
|
||||
| CodeQLProtocol.EvaluationCompletedEvent["body"]
|
||||
| undefined = undefined;
|
||||
|
||||
public constructor(
|
||||
private readonly session: DebugSession,
|
||||
private readonly controller: DebugController,
|
||||
) {}
|
||||
|
||||
public onWillReceiveMessage(
|
||||
message: CodeQLProtocol.AnyProtocolMessage,
|
||||
): void {
|
||||
switch (message.type) {
|
||||
case "request":
|
||||
this.onWillReceiveRequest(message);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
public onDidSendMessage(message: CodeQLProtocol.AnyProtocolMessage): void {
|
||||
void this.session;
|
||||
switch (message.type) {
|
||||
case "event":
|
||||
this.onDidSendEvent(message);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private onWillReceiveRequest(request: CodeQLProtocol.AnyRequest): void {
|
||||
switch (request.command) {
|
||||
case "launch":
|
||||
this.controller.handleEvent({
|
||||
kind: "launched",
|
||||
request,
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private onDidSendEvent(event: CodeQLProtocol.AnyEvent): void {
|
||||
switch (event.event) {
|
||||
case "codeql-evaluation-started":
|
||||
this.started = event.body;
|
||||
break;
|
||||
|
||||
case "codeql-evaluation-completed":
|
||||
this.completed = event.body;
|
||||
this.controller.handleEvent({
|
||||
kind: "evaluationCompleted",
|
||||
started: this.started!,
|
||||
results: {
|
||||
...this.started!,
|
||||
...this.completed!,
|
||||
outputDir: new QueryOutputDir(this.started!.outputDir),
|
||||
queryTarget: {
|
||||
queryPath: this.queryPath!,
|
||||
quickEvalPosition:
|
||||
this.started!.quickEvalContext?.quickEvalPosition,
|
||||
},
|
||||
dbPath: this.database!,
|
||||
},
|
||||
});
|
||||
break;
|
||||
|
||||
case "exited":
|
||||
this.controller.handleEvent({
|
||||
kind: "exited",
|
||||
body: event.body,
|
||||
});
|
||||
break;
|
||||
|
||||
case "stopped":
|
||||
this.controller.handleEvent({
|
||||
kind: "stopped",
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* An interesting event from the debug session. These are queued by the `DebugContoller`. The test
|
||||
* code consumes these events and asserts that they are in the correct order and have the correct
|
||||
* data.
|
||||
*/
|
||||
export type DebugEventKind =
|
||||
| "launched"
|
||||
| "evaluationCompleted"
|
||||
| "terminated"
|
||||
| "stopped"
|
||||
| "exited"
|
||||
| "sessionClosed";
|
||||
|
||||
export interface DebugEvent {
|
||||
kind: DebugEventKind;
|
||||
}
|
||||
|
||||
export interface LaunchedEvent extends DebugEvent {
|
||||
kind: "launched";
|
||||
request: CodeQLProtocol.LaunchRequest;
|
||||
}
|
||||
|
||||
export interface EvaluationCompletedEvent extends DebugEvent {
|
||||
kind: "evaluationCompleted";
|
||||
started: CodeQLProtocol.EvaluationStartedEvent["body"];
|
||||
results: CoreCompletedQuery;
|
||||
}
|
||||
|
||||
export interface TerminatedEvent extends DebugEvent {
|
||||
kind: "terminated";
|
||||
}
|
||||
|
||||
export interface StoppedEvent extends DebugEvent {
|
||||
kind: "stopped";
|
||||
}
|
||||
|
||||
export interface ExitedEvent extends DebugEvent {
|
||||
kind: "exited";
|
||||
body: CodeQLProtocol.ExitedEvent["body"];
|
||||
}
|
||||
|
||||
export interface SessionClosedEvent extends DebugEvent {
|
||||
kind: "sessionClosed";
|
||||
}
|
||||
|
||||
export type AnyDebugEvent =
|
||||
| LaunchedEvent
|
||||
| EvaluationCompletedEvent
|
||||
| StoppedEvent
|
||||
| ExitedEvent
|
||||
| TerminatedEvent
|
||||
| SessionClosedEvent;
|
||||
|
||||
/**
|
||||
* Exposes a simple facade over a debugging session. Test code invokes the various commands as
|
||||
* async functions, and consumes events reported by the session to ensure the correct sequence and
|
||||
* data.
|
||||
*/
|
||||
export class DebugController
|
||||
extends DisposableObject
|
||||
implements DebugAdapterTrackerFactory
|
||||
{
|
||||
/** Queue of events reported by the session. */
|
||||
private readonly eventQueue: AnyDebugEvent[] = [];
|
||||
/**
|
||||
* The index of the next event to be read from the queue. This index may be equal to the length of
|
||||
* the queue, in which case all events received so far have been consumed, and the next attempt to
|
||||
* consume an event will block waiting for that event.
|
||||
* */
|
||||
private nextEventIndex = 0;
|
||||
/**
|
||||
* If the client is currently blocked waiting for a new event, this property holds the `resolve()`
|
||||
* function that will resolve the promise on which the client is blocked.
|
||||
*/
|
||||
private resolver: Resolver<AnyDebugEvent> | undefined = undefined;
|
||||
|
||||
public constructor(private readonly appCommands: AppCommandManager) {
|
||||
super();
|
||||
this.push(debug.registerDebugAdapterTrackerFactory("codeql", this));
|
||||
this.push(
|
||||
debug.onDidTerminateDebugSession(
|
||||
this.handleDidTerminateDebugSession.bind(this),
|
||||
),
|
||||
);
|
||||
this.push(
|
||||
debug.onDidChangeActiveDebugSession(
|
||||
this.handleDidChangeActiveDebugSession.bind(this),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
public createDebugAdapterTracker(
|
||||
session: DebugSession,
|
||||
): ProviderResult<DebugAdapterTracker> {
|
||||
return new Tracker(session, this);
|
||||
}
|
||||
|
||||
public async createLaunchJson(config: QLDebugConfiguration): Promise<void> {
|
||||
const launchJsonPath = join(
|
||||
getOnDiskWorkspaceFolders()[0],
|
||||
".vscode/launch.json",
|
||||
);
|
||||
|
||||
await writeFile(
|
||||
launchJsonPath,
|
||||
JSON.stringify({
|
||||
version: "0.2.0",
|
||||
configurations: [config],
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts a debug session via the "codeQL.debugQuery" copmmand.
|
||||
*/
|
||||
public debugQuery(uri: Uri): Promise<void> {
|
||||
return this.appCommands.execute("codeQL.debugQuery", uri);
|
||||
}
|
||||
|
||||
public async startDebugging(
|
||||
config: QLDebugArgs,
|
||||
noDebug = false,
|
||||
): Promise<void> {
|
||||
const fullConfig: QLDebugConfiguration = {
|
||||
...config,
|
||||
name: "test",
|
||||
type: "codeql",
|
||||
request: "launch",
|
||||
};
|
||||
const options = noDebug
|
||||
? {
|
||||
noDebug: true,
|
||||
}
|
||||
: {};
|
||||
|
||||
return await this.appCommands.execute("workbench.action.debug.start", {
|
||||
config: fullConfig,
|
||||
...options,
|
||||
});
|
||||
}
|
||||
|
||||
public async startDebuggingSelection(config: QLDebugArgs): Promise<void> {
|
||||
return await this.startDebugging({
|
||||
...config,
|
||||
quickEval: true,
|
||||
});
|
||||
}
|
||||
|
||||
public async continueDebuggingSelection(): Promise<void> {
|
||||
return await this.appCommands.execute("codeQL.continueDebuggingSelection");
|
||||
}
|
||||
|
||||
public async stepInto(): Promise<void> {
|
||||
return await this.appCommands.execute("workbench.action.debug.stepInto");
|
||||
}
|
||||
|
||||
public async stepOver(): Promise<void> {
|
||||
return await this.appCommands.execute("workbench.action.debug.stepOver");
|
||||
}
|
||||
|
||||
public async stepOut(): Promise<void> {
|
||||
return await this.appCommands.execute("workbench.action.debug.stepOut");
|
||||
}
|
||||
|
||||
public handleEvent(event: AnyDebugEvent): void {
|
||||
this.eventQueue.push(event);
|
||||
if (this.resolver !== undefined) {
|
||||
// We were waiting for this one. Resolve it.
|
||||
this.nextEventIndex++;
|
||||
const resolver = this.resolver;
|
||||
this.resolver = undefined;
|
||||
resolver(event);
|
||||
}
|
||||
}
|
||||
|
||||
private handleDidTerminateDebugSession(_session: DebugSession): void {
|
||||
this.handleEvent({
|
||||
kind: "terminated",
|
||||
});
|
||||
}
|
||||
|
||||
private handleDidChangeActiveDebugSession(
|
||||
session: DebugSession | undefined,
|
||||
): void {
|
||||
if (session === undefined) {
|
||||
this.handleEvent({
|
||||
kind: "sessionClosed",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Consumes the next event in the queue. If all received messages have already been consumed, this
|
||||
* function blocks until another event is received.
|
||||
*/
|
||||
private async nextEvent(): Promise<AnyDebugEvent> {
|
||||
if (this.resolver !== undefined) {
|
||||
const error = new Error(
|
||||
"Attempt to wait for multiple debugger events at once.",
|
||||
);
|
||||
fail(error);
|
||||
throw error;
|
||||
} else {
|
||||
if (this.nextEventIndex < this.eventQueue.length) {
|
||||
// No need to wait.
|
||||
const event = this.eventQueue[this.nextEventIndex];
|
||||
this.nextEventIndex++;
|
||||
return Promise.resolve(event);
|
||||
} else {
|
||||
// No event available yet, so we need to wait.
|
||||
return new Promise((resolve, _reject) => {
|
||||
this.resolver = resolve;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Consume the next event in the queue, and assert that it is of the specified type.
|
||||
*/
|
||||
private async expectEvent<T extends DebugEvent>(kind: T["kind"]): Promise<T> {
|
||||
const event = await this.nextEvent();
|
||||
expect(event.kind).toBe(kind);
|
||||
return <T>event;
|
||||
}
|
||||
|
||||
public async expectLaunched(): Promise<LaunchedEvent> {
|
||||
return this.expectEvent<LaunchedEvent>("launched");
|
||||
}
|
||||
|
||||
public async expectExited(): Promise<ExitedEvent> {
|
||||
return this.expectEvent<ExitedEvent>("exited");
|
||||
}
|
||||
|
||||
public async expectCompleted(): Promise<EvaluationCompletedEvent> {
|
||||
return await this.expectEvent<EvaluationCompletedEvent>(
|
||||
"evaluationCompleted",
|
||||
);
|
||||
}
|
||||
|
||||
public async expectSucceeded(): Promise<EvaluationCompletedEvent> {
|
||||
const event = await this.expectCompleted();
|
||||
if (event.results.resultType !== QueryResultType.SUCCESS) {
|
||||
expect(event.results.message).toBe("success");
|
||||
}
|
||||
return event;
|
||||
}
|
||||
|
||||
public async expectFailed(): Promise<EvaluationCompletedEvent> {
|
||||
const event = await this.expectCompleted();
|
||||
expect(event.results.resultType).not.toEqual(QueryResultType.SUCCESS);
|
||||
return event;
|
||||
}
|
||||
|
||||
public async expectStopped(): Promise<StoppedEvent> {
|
||||
return await this.expectEvent<StoppedEvent>("stopped");
|
||||
}
|
||||
|
||||
public async expectTerminated(): Promise<TerminatedEvent> {
|
||||
return this.expectEvent<TerminatedEvent>("terminated");
|
||||
}
|
||||
|
||||
public async expectSessionClosed(): Promise<SessionClosedEvent> {
|
||||
return this.expectEvent<SessionClosedEvent>("sessionClosed");
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait the specified number of milliseconds, and fail the test if any events are received within
|
||||
* that timeframe.
|
||||
*/
|
||||
public async expectNoEvents(duration: number): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
setTimeout(() => {
|
||||
if (this.nextEventIndex < this.eventQueue.length) {
|
||||
const event = this.eventQueue[this.nextEventIndex];
|
||||
reject(
|
||||
new Error(
|
||||
`Did not expect to receive any events, but received '${event.kind}'.`,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
resolve();
|
||||
}
|
||||
}, duration);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a function with a new instance of `DebugContoller`. Once the function completes, the
|
||||
* debug controller is cleaned up.
|
||||
*/
|
||||
export async function withDebugController<T>(
|
||||
appCommands: AppCommandManager,
|
||||
op: (controller: DebugController) => Promise<T>,
|
||||
): Promise<T> {
|
||||
await workspace.getConfiguration().update("codeQL.canary", true);
|
||||
try {
|
||||
const controller = new DebugController(appCommands);
|
||||
try {
|
||||
try {
|
||||
const result = await op(controller);
|
||||
// The test should have consumed all expected events. Wait a couple seconds to make sure
|
||||
// no more come in.
|
||||
await controller.expectNoEvents(2000);
|
||||
return result;
|
||||
} finally {
|
||||
await debug.stopDebugging();
|
||||
}
|
||||
} finally {
|
||||
// In a separate finally block so that the controller gets disposed even if `stopDebugging()`
|
||||
// fails.
|
||||
controller.dispose();
|
||||
}
|
||||
} finally {
|
||||
await workspace.getConfiguration().update("codeQL.canary", false);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
import { Selection, Uri, window, workspace } from "vscode";
|
||||
import { join } from "path";
|
||||
|
||||
import { DatabaseManager } from "../../../../src/local-databases";
|
||||
import {
|
||||
cleanDatabases,
|
||||
ensureTestDatabase,
|
||||
getActivatedExtension,
|
||||
} from "../../global.helper";
|
||||
import { describeWithCodeQL } from "../../cli";
|
||||
import { withDebugController } from "./debug-controller";
|
||||
import { CodeQLCliServer } from "../../../../src/cli";
|
||||
import { QueryOutputDir } from "../../../../src/run-queries-shared";
|
||||
import { createVSCodeCommandManager } from "../../../../src/common/vscode/commands";
|
||||
import { AllCommands } from "../../../../src/common/commands";
|
||||
|
||||
async function selectForQuickEval(
|
||||
path: string,
|
||||
line: number,
|
||||
column: number,
|
||||
endLine: number,
|
||||
endColumn: number,
|
||||
): Promise<void> {
|
||||
const document = await workspace.openTextDocument(path);
|
||||
const editor = await window.showTextDocument(document);
|
||||
editor.selection = new Selection(line, column, endLine, endColumn);
|
||||
}
|
||||
|
||||
async function getResultCount(
|
||||
outputDir: QueryOutputDir,
|
||||
cli: CodeQLCliServer,
|
||||
): Promise<number> {
|
||||
const info = await cli.bqrsInfo(outputDir.bqrsPath, 100);
|
||||
const resultSet = info["result-sets"][0];
|
||||
return resultSet.rows;
|
||||
}
|
||||
|
||||
/**
|
||||
* Integration tests for the query debugger
|
||||
*/
|
||||
describeWithCodeQL()("Debugger", () => {
|
||||
let databaseManager: DatabaseManager;
|
||||
let cli: CodeQLCliServer;
|
||||
const appCommands = createVSCodeCommandManager<AllCommands>();
|
||||
const simpleQueryPath = join(__dirname, "..", "data", "simple-query.ql");
|
||||
const quickEvalQueryPath = join(__dirname, "..", "data", "QuickEvalQuery.ql");
|
||||
const quickEvalLibPath = join(__dirname, "..", "data", "QuickEvalLib.qll");
|
||||
|
||||
beforeEach(async () => {
|
||||
const extension = await getActivatedExtension();
|
||||
databaseManager = extension.databaseManager;
|
||||
cli = extension.cliServer;
|
||||
cli.quiet = true;
|
||||
|
||||
await ensureTestDatabase(databaseManager, cli);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await cleanDatabases(databaseManager);
|
||||
});
|
||||
|
||||
it("should debug a query and keep the session active", async () => {
|
||||
await withDebugController(appCommands, async (controller) => {
|
||||
await controller.debugQuery(Uri.file(simpleQueryPath));
|
||||
await controller.expectLaunched();
|
||||
await controller.expectSucceeded();
|
||||
await controller.expectStopped();
|
||||
});
|
||||
});
|
||||
|
||||
it("should run a query and then stop debugging", async () => {
|
||||
await withDebugController(appCommands, async (controller) => {
|
||||
await controller.startDebugging(
|
||||
{
|
||||
query: simpleQueryPath,
|
||||
},
|
||||
true,
|
||||
);
|
||||
await controller.expectLaunched();
|
||||
await controller.expectSucceeded();
|
||||
await controller.expectExited();
|
||||
await controller.expectTerminated();
|
||||
await controller.expectSessionClosed();
|
||||
});
|
||||
});
|
||||
|
||||
it("should run a quick evaluation", async () => {
|
||||
await withDebugController(appCommands, async (controller) => {
|
||||
await selectForQuickEval(quickEvalQueryPath, 18, 5, 18, 22);
|
||||
|
||||
// Don't specify a query path, so we'll default to the active document ("QuickEvalQuery.ql")
|
||||
await controller.startDebuggingSelection({});
|
||||
await controller.expectLaunched();
|
||||
const result = await controller.expectSucceeded();
|
||||
expect(result.started.quickEvalContext).toBeDefined();
|
||||
expect(result.started.quickEvalContext!.quickEvalText).toBe(
|
||||
"InterestingNumber",
|
||||
);
|
||||
expect(result.results.queryTarget.quickEvalPosition).toBeDefined();
|
||||
expect(await getResultCount(result.results.outputDir, cli)).toBe(8);
|
||||
await controller.expectStopped();
|
||||
});
|
||||
});
|
||||
|
||||
it("should run a quick evaluation on a library without any query context", async () => {
|
||||
await withDebugController(appCommands, async (controller) => {
|
||||
await selectForQuickEval(quickEvalLibPath, 4, 15, 4, 32);
|
||||
|
||||
// Don't specify a query path, so we'll default to the active document ("QuickEvalLib.qll")
|
||||
await controller.startDebuggingSelection({});
|
||||
await controller.expectLaunched();
|
||||
const result = await controller.expectSucceeded();
|
||||
expect(result.started.quickEvalContext).toBeDefined();
|
||||
expect(result.started.quickEvalContext!.quickEvalText).toBe(
|
||||
"InterestingNumber",
|
||||
);
|
||||
expect(result.results.queryTarget.quickEvalPosition).toBeDefined();
|
||||
expect(await getResultCount(result.results.outputDir, cli)).toBe(0);
|
||||
await controller.expectStopped();
|
||||
});
|
||||
});
|
||||
|
||||
it("should run a quick evaluation on a library in the context of a specific query", async () => {
|
||||
await withDebugController(appCommands, async (controller) => {
|
||||
await selectForQuickEval(quickEvalLibPath, 4, 15, 4, 32);
|
||||
|
||||
await controller.startDebuggingSelection({
|
||||
query: quickEvalQueryPath, // The query context. This query extends the abstract class.
|
||||
});
|
||||
await controller.expectLaunched();
|
||||
const result = await controller.expectSucceeded();
|
||||
expect(result.started.quickEvalContext).toBeDefined();
|
||||
expect(result.started.quickEvalContext!.quickEvalText).toBe(
|
||||
"InterestingNumber",
|
||||
);
|
||||
expect(result.results.queryTarget.quickEvalPosition).toBeDefined();
|
||||
expect(await getResultCount(result.results.outputDir, cli)).toBe(8);
|
||||
await controller.expectStopped();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -5,18 +5,11 @@ import * as messages from "../../../src/pure/new-messages";
|
||||
import * as qsClient from "../../../src/query-server/queryserver-client";
|
||||
import * as cli from "../../../src/cli";
|
||||
import { CellValue } from "../../../src/pure/bqrs-cli-types";
|
||||
import { Uri } from "vscode";
|
||||
import { describeWithCodeQL } from "../cli";
|
||||
import { QueryServerClient } from "../../../src/query-server/queryserver-client";
|
||||
import { extLogger, ProgressReporter } from "../../../src/common";
|
||||
import { QueryResultType } from "../../../src/pure/new-messages";
|
||||
import {
|
||||
cleanDatabases,
|
||||
dbLoc,
|
||||
getActivatedExtension,
|
||||
storagePath,
|
||||
} from "../global.helper";
|
||||
import { importArchiveDatabase } from "../../../src/databaseFetcher";
|
||||
import { ensureTestDatabase, getActivatedExtension } from "../global.helper";
|
||||
import { createMockApp } from "../../__mocks__/appMock";
|
||||
|
||||
const baseDir = join(__dirname, "../../../test/data");
|
||||
@@ -142,24 +135,11 @@ describeWithCodeQL()("using the new query server", () => {
|
||||
await qs.startQueryServer();
|
||||
|
||||
// Unlike the old query sevre the new one wants a database and the empty direcrtory is not valid.
|
||||
// Add a database, but make sure the database manager is empty first
|
||||
await cleanDatabases(extension.databaseManager);
|
||||
const uri = Uri.file(dbLoc);
|
||||
const maybeDbItem = await importArchiveDatabase(
|
||||
app.commands,
|
||||
uri.toString(true),
|
||||
const dbItem = await ensureTestDatabase(
|
||||
extension.databaseManager,
|
||||
storagePath,
|
||||
() => {
|
||||
/**ignore progress */
|
||||
},
|
||||
token,
|
||||
undefined,
|
||||
);
|
||||
|
||||
if (!maybeDbItem) {
|
||||
throw new Error("Could not import database");
|
||||
}
|
||||
db = maybeDbItem.databaseUri.fsPath;
|
||||
db = dbItem.databaseUri.fsPath;
|
||||
});
|
||||
|
||||
for (const queryTestCase of queryTestCases) {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { CancellationToken, ExtensionContext, Uri } from "vscode";
|
||||
import { CancellationToken, ExtensionContext, Range, Uri } from "vscode";
|
||||
import { join, dirname } from "path";
|
||||
import {
|
||||
pathExistsSync,
|
||||
@@ -12,20 +12,68 @@ import { load, dump } from "js-yaml";
|
||||
import { DatabaseItem, DatabaseManager } from "../../../src/local-databases";
|
||||
import {
|
||||
cleanDatabases,
|
||||
dbLoc,
|
||||
ensureTestDatabase,
|
||||
getActivatedExtension,
|
||||
storagePath,
|
||||
} from "../global.helper";
|
||||
import { importArchiveDatabase } from "../../../src/databaseFetcher";
|
||||
import { CliVersionConstraint, CodeQLCliServer } from "../../../src/cli";
|
||||
import { describeWithCodeQL } from "../cli";
|
||||
import { QueryRunner } from "../../../src/queryRunner";
|
||||
import { CoreCompletedQuery, QueryRunner } from "../../../src/queryRunner";
|
||||
import { SELECT_QUERY_NAME } from "../../../src/contextual/locationFinder";
|
||||
import { createMockCommandManager } from "../../__mocks__/commandsMock";
|
||||
import { LocalQueries } from "../../../src/local-queries";
|
||||
import { QueryResultType } from "../../../src/pure/new-messages";
|
||||
import { createVSCodeCommandManager } from "../../../src/common/vscode/commands";
|
||||
import { AllCommands, QueryServerCommands } from "../../../src/common/commands";
|
||||
import {
|
||||
AllCommands,
|
||||
AppCommandManager,
|
||||
QueryServerCommands,
|
||||
} from "../../../src/common/commands";
|
||||
import { ProgressCallback } from "../../../src/progress";
|
||||
import { withDebugController } from "./debugger/debug-controller";
|
||||
|
||||
type DebugMode = "localQueries" | "debug";
|
||||
|
||||
async function compileAndRunQuery(
|
||||
mode: DebugMode,
|
||||
appCommands: AppCommandManager,
|
||||
localQueries: LocalQueries,
|
||||
quickEval: boolean,
|
||||
queryUri: Uri,
|
||||
progress: ProgressCallback,
|
||||
token: CancellationToken,
|
||||
databaseItem: DatabaseItem | undefined,
|
||||
range?: Range,
|
||||
): Promise<CoreCompletedQuery> {
|
||||
switch (mode) {
|
||||
case "localQueries":
|
||||
return await localQueries.compileAndRunQueryInternal(
|
||||
quickEval,
|
||||
queryUri,
|
||||
progress,
|
||||
token,
|
||||
databaseItem,
|
||||
range,
|
||||
);
|
||||
|
||||
case "debug":
|
||||
return await withDebugController(appCommands, async (controller) => {
|
||||
await controller.startDebugging(
|
||||
{
|
||||
query: queryUri.fsPath,
|
||||
},
|
||||
true,
|
||||
);
|
||||
await controller.expectLaunched();
|
||||
const succeeded = await controller.expectSucceeded();
|
||||
await controller.expectExited();
|
||||
await controller.expectTerminated();
|
||||
await controller.expectSessionClosed();
|
||||
|
||||
return succeeded.results;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const MODES: DebugMode[] = ["localQueries", "debug"];
|
||||
|
||||
/**
|
||||
* Integration tests for queries
|
||||
@@ -71,23 +119,7 @@ describeWithCodeQL()("Queries", () => {
|
||||
},
|
||||
} as CancellationToken;
|
||||
|
||||
// Add a database, but make sure the database manager is empty first
|
||||
await cleanDatabases(databaseManager);
|
||||
const uri = Uri.file(dbLoc);
|
||||
const maybeDbItem = await importArchiveDatabase(
|
||||
createMockCommandManager(),
|
||||
uri.toString(true),
|
||||
databaseManager,
|
||||
storagePath,
|
||||
progress,
|
||||
token,
|
||||
cli,
|
||||
);
|
||||
|
||||
if (!maybeDbItem) {
|
||||
throw new Error("Could not import database");
|
||||
}
|
||||
dbItem = maybeDbItem;
|
||||
dbItem = await ensureTestDatabase(databaseManager, cli);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
@@ -96,7 +128,7 @@ describeWithCodeQL()("Queries", () => {
|
||||
await cleanDatabases(databaseManager);
|
||||
});
|
||||
|
||||
describe("extension packs", () => {
|
||||
describe.each(MODES)("extension packs (%s)", (mode) => {
|
||||
const queryUsingExtensionPath = join(
|
||||
__dirname,
|
||||
"../..",
|
||||
@@ -139,7 +171,10 @@ describeWithCodeQL()("Queries", () => {
|
||||
}
|
||||
|
||||
async function runQueryWithExtensions() {
|
||||
const result = await localQueries.compileAndRunQueryInternal(
|
||||
const result = await compileAndRunQuery(
|
||||
mode,
|
||||
appCommandManager,
|
||||
localQueries,
|
||||
false,
|
||||
Uri.file(queryUsingExtensionPath),
|
||||
progress,
|
||||
@@ -167,75 +202,85 @@ describeWithCodeQL()("Queries", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("should run a query", async () => {
|
||||
const queryPath = join(__dirname, "data", "simple-query.ql");
|
||||
const result = await localQueries.compileAndRunQueryInternal(
|
||||
false,
|
||||
Uri.file(queryPath),
|
||||
progress,
|
||||
token,
|
||||
dbItem,
|
||||
undefined,
|
||||
);
|
||||
describe.each(MODES)("running queries (%s)", (mode) => {
|
||||
it("should run a query", async () => {
|
||||
const queryPath = join(__dirname, "data", "simple-query.ql");
|
||||
const result = await compileAndRunQuery(
|
||||
mode,
|
||||
appCommandManager,
|
||||
localQueries,
|
||||
false,
|
||||
Uri.file(queryPath),
|
||||
progress,
|
||||
token,
|
||||
dbItem,
|
||||
undefined,
|
||||
);
|
||||
|
||||
// just check that the query was successful
|
||||
expect(result.resultType).toBe(QueryResultType.SUCCESS);
|
||||
// just check that the query was successful
|
||||
expect(result.resultType).toBe(QueryResultType.SUCCESS);
|
||||
});
|
||||
|
||||
// Asserts a fix for bug https://github.com/github/vscode-codeql/issues/733
|
||||
it("should restart the database and run a query", async () => {
|
||||
await appCommandManager.execute("codeQL.restartQueryServer");
|
||||
const queryPath = join(__dirname, "data", "simple-query.ql");
|
||||
const result = await compileAndRunQuery(
|
||||
mode,
|
||||
appCommandManager,
|
||||
localQueries,
|
||||
false,
|
||||
Uri.file(queryPath),
|
||||
progress,
|
||||
token,
|
||||
dbItem,
|
||||
undefined,
|
||||
);
|
||||
|
||||
expect(result.resultType).toBe(QueryResultType.SUCCESS);
|
||||
});
|
||||
});
|
||||
|
||||
// Asserts a fix for bug https://github.com/github/vscode-codeql/issues/733
|
||||
it("should restart the database and run a query", async () => {
|
||||
await appCommandManager.execute("codeQL.restartQueryServer");
|
||||
const queryPath = join(__dirname, "data", "simple-query.ql");
|
||||
const result = await localQueries.compileAndRunQueryInternal(
|
||||
false,
|
||||
Uri.file(queryPath),
|
||||
progress,
|
||||
token,
|
||||
dbItem,
|
||||
undefined,
|
||||
);
|
||||
describe("quick query", () => {
|
||||
it("should create a quick query", async () => {
|
||||
await queryServerCommandManager.execute("codeQL.quickQuery");
|
||||
|
||||
expect(result.resultType).toBe(QueryResultType.SUCCESS);
|
||||
});
|
||||
// should have created the quick query file and query pack file
|
||||
expect(pathExistsSync(qlFile)).toBe(true);
|
||||
expect(pathExistsSync(qlpackFile)).toBe(true);
|
||||
|
||||
it("should create a quick query", async () => {
|
||||
await queryServerCommandManager.execute("codeQL.quickQuery");
|
||||
const qlpackContents: any = await load(readFileSync(qlpackFile, "utf8"));
|
||||
// Should have chosen the js libraries
|
||||
expect(qlpackContents.dependencies["codeql/javascript-all"]).toBe("*");
|
||||
|
||||
// should have created the quick query file and query pack file
|
||||
expect(pathExistsSync(qlFile)).toBe(true);
|
||||
expect(pathExistsSync(qlpackFile)).toBe(true);
|
||||
// Should also have a codeql-pack.lock.yml file
|
||||
const packFileToUse = pathExistsSync(qlpackLockFile)
|
||||
? qlpackLockFile
|
||||
: oldQlpackLockFile;
|
||||
const qlpackLock: any = await load(readFileSync(packFileToUse, "utf8"));
|
||||
expect(!!qlpackLock.dependencies["codeql/javascript-all"].version).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
const qlpackContents: any = await load(readFileSync(qlpackFile, "utf8"));
|
||||
// Should have chosen the js libraries
|
||||
expect(qlpackContents.dependencies["codeql/javascript-all"]).toBe("*");
|
||||
it("should avoid creating a quick query", async () => {
|
||||
mkdirpSync(dirname(qlpackFile));
|
||||
writeFileSync(
|
||||
qlpackFile,
|
||||
dump({
|
||||
name: "quick-query",
|
||||
version: "1.0.0",
|
||||
dependencies: {
|
||||
"codeql/javascript-all": "*",
|
||||
},
|
||||
}),
|
||||
);
|
||||
writeFileSync(qlFile, "xxx");
|
||||
await queryServerCommandManager.execute("codeQL.quickQuery");
|
||||
|
||||
// Should also have a codeql-pack.lock.yml file
|
||||
const packFileToUse = pathExistsSync(qlpackLockFile)
|
||||
? qlpackLockFile
|
||||
: oldQlpackLockFile;
|
||||
const qlpackLock: any = await load(readFileSync(packFileToUse, "utf8"));
|
||||
expect(!!qlpackLock.dependencies["codeql/javascript-all"].version).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it("should avoid creating a quick query", async () => {
|
||||
mkdirpSync(dirname(qlpackFile));
|
||||
writeFileSync(
|
||||
qlpackFile,
|
||||
dump({
|
||||
name: "quick-query",
|
||||
version: "1.0.0",
|
||||
dependencies: {
|
||||
"codeql/javascript-all": "*",
|
||||
},
|
||||
}),
|
||||
);
|
||||
writeFileSync(qlFile, "xxx");
|
||||
await queryServerCommandManager.execute("codeQL.quickQuery");
|
||||
|
||||
// should not have created the quick query file because database schema hasn't changed
|
||||
expect(readFileSync(qlFile, "utf8")).toBe("xxx");
|
||||
// should not have created the quick query file because database schema hasn't changed
|
||||
expect(readFileSync(qlFile, "utf8")).toBe("xxx");
|
||||
});
|
||||
});
|
||||
|
||||
function safeDel(file: string) {
|
||||
|
||||
@@ -1,12 +1,19 @@
|
||||
import { join } from "path";
|
||||
import { load, dump } from "js-yaml";
|
||||
import { realpathSync, readFileSync, writeFileSync } from "fs-extra";
|
||||
import { CancellationToken, extensions } from "vscode";
|
||||
import { DatabaseManager } from "../../src/local-databases";
|
||||
import {
|
||||
CancellationToken,
|
||||
CancellationTokenSource,
|
||||
Uri,
|
||||
extensions,
|
||||
} from "vscode";
|
||||
import { DatabaseItem, DatabaseManager } from "../../src/local-databases";
|
||||
import { CodeQLCliServer } from "../../src/cli";
|
||||
import { removeWorkspaceRefs } from "../../src/variant-analysis/run-remote-query";
|
||||
import { CodeQLExtensionInterface } from "../../src/extension";
|
||||
import { ProgressCallback } from "../../src/progress";
|
||||
import { importArchiveDatabase } from "../../src/databaseFetcher";
|
||||
import { createMockCommandManager } from "../__mocks__/commandsMock";
|
||||
|
||||
// This file contains helpers shared between tests that work with an activated extension.
|
||||
|
||||
@@ -21,6 +28,35 @@ export const dbLoc = join(
|
||||
);
|
||||
export let storagePath: string;
|
||||
|
||||
/**
|
||||
* Removes any existing databases from the database panel, and loads the test database.
|
||||
*/
|
||||
export async function ensureTestDatabase(
|
||||
databaseManager: DatabaseManager,
|
||||
cli: CodeQLCliServer | undefined,
|
||||
): Promise<DatabaseItem> {
|
||||
// Add a database, but make sure the database manager is empty first
|
||||
await cleanDatabases(databaseManager);
|
||||
const uri = Uri.file(dbLoc);
|
||||
const maybeDbItem = await importArchiveDatabase(
|
||||
createMockCommandManager(),
|
||||
uri.toString(true),
|
||||
databaseManager,
|
||||
storagePath,
|
||||
(_p) => {
|
||||
/**/
|
||||
},
|
||||
new CancellationTokenSource().token,
|
||||
cli,
|
||||
);
|
||||
|
||||
if (!maybeDbItem) {
|
||||
throw new Error("Could not import database");
|
||||
}
|
||||
|
||||
return maybeDbItem;
|
||||
}
|
||||
|
||||
export function setStoragePath(path: string) {
|
||||
storagePath = path;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { resolve, join } from "path";
|
||||
import * as vscode from "vscode";
|
||||
import { Uri } from "vscode";
|
||||
import { determineSelectedQuery } from "../../../src/run-queries-shared";
|
||||
import {
|
||||
getQuickEvalContext,
|
||||
validateQueryUri,
|
||||
} from "../../../src/run-queries-shared";
|
||||
|
||||
async function showQlDocument(name: string): Promise<vscode.TextDocument> {
|
||||
const folderPath = vscode.workspace.workspaceFolders![0].uri.fsPath;
|
||||
@@ -14,43 +17,47 @@ async function showQlDocument(name: string): Promise<vscode.TextDocument> {
|
||||
export function run() {
|
||||
describe("Determining selected query", () => {
|
||||
it("should allow ql files to be queried", async () => {
|
||||
const q = await determineSelectedQuery(
|
||||
const queryPath = validateQueryUri(
|
||||
Uri.parse("file:///tmp/queryname.ql"),
|
||||
false,
|
||||
);
|
||||
expect(q.queryPath).toBe(join("/", "tmp", "queryname.ql"));
|
||||
expect(q.quickEvalPosition).toBeUndefined();
|
||||
expect(queryPath).toBe(join("/", "tmp", "queryname.ql"));
|
||||
});
|
||||
|
||||
it("should allow ql files to be quick-evaled", async () => {
|
||||
const doc = await showQlDocument("query.ql");
|
||||
const q = await determineSelectedQuery(doc.uri, true);
|
||||
await showQlDocument("query.ql");
|
||||
const q = await getQuickEvalContext(undefined);
|
||||
expect(
|
||||
q.queryPath.endsWith(join("ql-vscode", "test", "data", "query.ql")),
|
||||
q.quickEvalPosition.fileName.endsWith(
|
||||
join("ql-vscode", "test", "data", "query.ql"),
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("should allow qll files to be quick-evaled", async () => {
|
||||
const doc = await showQlDocument("library.qll");
|
||||
const q = await determineSelectedQuery(doc.uri, true);
|
||||
await showQlDocument("library.qll");
|
||||
const q = await getQuickEvalContext(undefined);
|
||||
expect(
|
||||
q.queryPath.endsWith(join("ql-vscode", "test", "data", "library.qll")),
|
||||
q.quickEvalPosition.fileName.endsWith(
|
||||
join("ql-vscode", "test", "data", "library.qll"),
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("should reject non-ql files when running a query", async () => {
|
||||
await expect(
|
||||
determineSelectedQuery(Uri.parse("file:///tmp/queryname.txt"), false),
|
||||
).rejects.toThrow("The selected resource is not a CodeQL query file");
|
||||
await expect(
|
||||
determineSelectedQuery(Uri.parse("file:///tmp/queryname.qll"), false),
|
||||
).rejects.toThrow("The selected resource is not a CodeQL query file");
|
||||
expect(() =>
|
||||
validateQueryUri(Uri.parse("file:///tmp/queryname.txt"), false),
|
||||
).toThrow("The selected resource is not a CodeQL query file");
|
||||
expect(() =>
|
||||
validateQueryUri(Uri.parse("file:///tmp/queryname.qll"), false),
|
||||
).toThrow("The selected resource is not a CodeQL query file");
|
||||
});
|
||||
|
||||
it("should reject non-ql[l] files when running a quick eval", async () => {
|
||||
await expect(
|
||||
determineSelectedQuery(Uri.parse("file:///tmp/queryname.txt"), true),
|
||||
).rejects.toThrow("The selected resource is not a CodeQL file");
|
||||
await showQlDocument("textfile.txt");
|
||||
await expect(getQuickEvalContext(undefined)).rejects.toThrow(
|
||||
"The selected resource is not a CodeQL file",
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,205 +1,175 @@
|
||||
import { Uri, WorkspaceFolder } from "vscode";
|
||||
import {
|
||||
CancellationTokenSource,
|
||||
Range,
|
||||
TestItem,
|
||||
TestItemCollection,
|
||||
TestRun,
|
||||
TestRunRequest,
|
||||
Uri,
|
||||
WorkspaceFolder,
|
||||
tests,
|
||||
} from "vscode";
|
||||
|
||||
import { QLTestAdapter } from "../../../src/test-adapter";
|
||||
import { CodeQLCliServer } from "../../../src/cli";
|
||||
import {
|
||||
DatabaseItem,
|
||||
DatabaseItemImpl,
|
||||
DatabaseManager,
|
||||
FullDatabaseOptions,
|
||||
} from "../../../src/local-databases";
|
||||
import { DatabaseManager } from "../../../src/local-databases";
|
||||
import { mockedObject } from "../utils/mocking.helpers";
|
||||
import { TestRunner } from "../../../src/test-runner";
|
||||
import {
|
||||
createMockCliServerForTestRun,
|
||||
mockEmptyDatabaseManager,
|
||||
mockTestsInfo,
|
||||
} from "./test-runner-helpers";
|
||||
import { TestManager } from "../../../src/test-manager";
|
||||
import { createMockApp } from "../../__mocks__/appMock";
|
||||
|
||||
jest.mock("fs-extra", () => {
|
||||
const original = jest.requireActual("fs-extra");
|
||||
return {
|
||||
...original,
|
||||
access: jest.fn(),
|
||||
};
|
||||
});
|
||||
type IdTestItemPair = [id: string, testItem: TestItem];
|
||||
|
||||
describe("test-adapter", () => {
|
||||
let adapter: QLTestAdapter;
|
||||
let testRunner: TestRunner;
|
||||
let fakeDatabaseManager: DatabaseManager;
|
||||
let currentDatabaseItem: DatabaseItem | undefined;
|
||||
let databaseItems: DatabaseItem[] = [];
|
||||
const openDatabaseSpy = jest.fn();
|
||||
const removeDatabaseItemSpy = jest.fn();
|
||||
const renameDatabaseItemSpy = jest.fn();
|
||||
const setCurrentDatabaseItemSpy = jest.fn();
|
||||
const runTestsSpy = jest.fn();
|
||||
const resolveTestsSpy = jest.fn();
|
||||
const resolveQlpacksSpy = jest.fn();
|
||||
|
||||
const preTestDatabaseItem = new DatabaseItemImpl(
|
||||
Uri.file("/path/to/test/dir/dir.testproj"),
|
||||
undefined,
|
||||
mockedObject<FullDatabaseOptions>({ displayName: "custom display name" }),
|
||||
(_) => {
|
||||
/* no change event listener */
|
||||
},
|
||||
);
|
||||
const postTestDatabaseItem = new DatabaseItemImpl(
|
||||
Uri.file("/path/to/test/dir/dir.testproj"),
|
||||
undefined,
|
||||
mockedObject<FullDatabaseOptions>({ displayName: "default name" }),
|
||||
(_) => {
|
||||
/* no change event listener */
|
||||
},
|
||||
);
|
||||
let fakeCliServer: CodeQLCliServer;
|
||||
|
||||
beforeEach(() => {
|
||||
mockRunTests();
|
||||
openDatabaseSpy.mockResolvedValue(postTestDatabaseItem);
|
||||
removeDatabaseItemSpy.mockResolvedValue(undefined);
|
||||
renameDatabaseItemSpy.mockResolvedValue(undefined);
|
||||
setCurrentDatabaseItemSpy.mockResolvedValue(undefined);
|
||||
resolveQlpacksSpy.mockResolvedValue({});
|
||||
resolveTestsSpy.mockResolvedValue([]);
|
||||
fakeDatabaseManager = mockedObject<DatabaseManager>(
|
||||
{
|
||||
openDatabase: openDatabaseSpy,
|
||||
removeDatabaseItem: removeDatabaseItemSpy,
|
||||
renameDatabaseItem: renameDatabaseItemSpy,
|
||||
setCurrentDatabaseItem: setCurrentDatabaseItemSpy,
|
||||
},
|
||||
{
|
||||
dynamicProperties: {
|
||||
currentDatabaseItem: () => currentDatabaseItem,
|
||||
databaseItems: () => databaseItems,
|
||||
},
|
||||
},
|
||||
);
|
||||
fakeDatabaseManager = mockEmptyDatabaseManager();
|
||||
|
||||
jest.spyOn(preTestDatabaseItem, "isAffectedByTest").mockResolvedValue(true);
|
||||
const mockCli = createMockCliServerForTestRun();
|
||||
fakeCliServer = mockCli.cliServer;
|
||||
|
||||
adapter = new QLTestAdapter(
|
||||
testRunner = new TestRunner(fakeDatabaseManager, fakeCliServer);
|
||||
});
|
||||
|
||||
it("legacy test adapter should run some tests", async () => {
|
||||
const adapter = new QLTestAdapter(
|
||||
mockedObject<WorkspaceFolder>({
|
||||
name: "ABC",
|
||||
uri: Uri.parse("file:/ab/c"),
|
||||
}),
|
||||
mockedObject<CodeQLCliServer>({
|
||||
runTests: runTestsSpy,
|
||||
resolveQlpacks: resolveQlpacksSpy,
|
||||
resolveTests: resolveTestsSpy,
|
||||
}),
|
||||
fakeDatabaseManager,
|
||||
testRunner,
|
||||
fakeCliServer,
|
||||
);
|
||||
});
|
||||
|
||||
it("should run some tests", async () => {
|
||||
const listenerSpy = jest.fn();
|
||||
adapter.testStates(listenerSpy);
|
||||
const testsPath = Uri.parse("file:/ab/c").fsPath;
|
||||
const dPath = Uri.parse("file:/ab/c/d.ql").fsPath;
|
||||
const gPath = Uri.parse("file:/ab/c/e/f/g.ql").fsPath;
|
||||
const hPath = Uri.parse("file:/ab/c/e/f/h.ql").fsPath;
|
||||
|
||||
await adapter.run([testsPath]);
|
||||
await adapter.run([mockTestsInfo.testsPath]);
|
||||
|
||||
expect(listenerSpy).toBeCalledTimes(5);
|
||||
|
||||
expect(listenerSpy).toHaveBeenNthCalledWith(1, {
|
||||
type: "started",
|
||||
tests: [testsPath],
|
||||
tests: [mockTestsInfo.testsPath],
|
||||
});
|
||||
expect(listenerSpy).toHaveBeenNthCalledWith(2, {
|
||||
type: "test",
|
||||
state: "passed",
|
||||
test: dPath,
|
||||
test: mockTestsInfo.dPath,
|
||||
message: undefined,
|
||||
decorations: [],
|
||||
});
|
||||
expect(listenerSpy).toHaveBeenNthCalledWith(3, {
|
||||
type: "test",
|
||||
state: "errored",
|
||||
test: gPath,
|
||||
message: `\ncompilation error: ${gPath}\nERROR: abc\n`,
|
||||
test: mockTestsInfo.gPath,
|
||||
message: `\ncompilation error: ${mockTestsInfo.gPath}\nERROR: abc\n`,
|
||||
decorations: [{ line: 1, message: "abc" }],
|
||||
});
|
||||
expect(listenerSpy).toHaveBeenNthCalledWith(4, {
|
||||
type: "test",
|
||||
state: "failed",
|
||||
test: hPath,
|
||||
message: `\nfailed: ${hPath}\njkh\ntuv\n`,
|
||||
test: mockTestsInfo.hPath,
|
||||
message: `\nfailed: ${mockTestsInfo.hPath}\njkh\ntuv\n`,
|
||||
decorations: [],
|
||||
});
|
||||
expect(listenerSpy).toHaveBeenNthCalledWith(5, { type: "finished" });
|
||||
});
|
||||
|
||||
it("should reregister testproj databases around test run", async () => {
|
||||
currentDatabaseItem = preTestDatabaseItem;
|
||||
databaseItems = [preTestDatabaseItem];
|
||||
await adapter.run(["/path/to/test/dir"]);
|
||||
it("native test manager should run some tests", async () => {
|
||||
const enqueuedSpy = jest.fn();
|
||||
const passedSpy = jest.fn();
|
||||
const erroredSpy = jest.fn();
|
||||
const failedSpy = jest.fn();
|
||||
const endSpy = jest.fn();
|
||||
|
||||
expect(removeDatabaseItemSpy.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
runTestsSpy.mock.invocationCallOrder[0],
|
||||
const testController = tests.createTestController("codeql", "CodeQL Tests");
|
||||
testController.createTestRun = jest.fn().mockImplementation(() =>
|
||||
mockedObject<TestRun>({
|
||||
enqueued: enqueuedSpy,
|
||||
passed: passedSpy,
|
||||
errored: erroredSpy,
|
||||
failed: failedSpy,
|
||||
end: endSpy,
|
||||
}),
|
||||
);
|
||||
expect(openDatabaseSpy.mock.invocationCallOrder[0]).toBeGreaterThan(
|
||||
runTestsSpy.mock.invocationCallOrder[0],
|
||||
);
|
||||
expect(renameDatabaseItemSpy.mock.invocationCallOrder[0]).toBeGreaterThan(
|
||||
openDatabaseSpy.mock.invocationCallOrder[0],
|
||||
);
|
||||
expect(
|
||||
setCurrentDatabaseItemSpy.mock.invocationCallOrder[0],
|
||||
).toBeGreaterThan(openDatabaseSpy.mock.invocationCallOrder[0]);
|
||||
|
||||
expect(removeDatabaseItemSpy).toBeCalledTimes(1);
|
||||
expect(removeDatabaseItemSpy).toBeCalledWith(
|
||||
expect.anything(),
|
||||
expect.anything(),
|
||||
preTestDatabaseItem,
|
||||
const testManager = new TestManager(
|
||||
createMockApp({}),
|
||||
testRunner,
|
||||
fakeCliServer,
|
||||
testController,
|
||||
);
|
||||
|
||||
expect(openDatabaseSpy).toBeCalledTimes(1);
|
||||
expect(openDatabaseSpy).toBeCalledWith(
|
||||
expect.anything(),
|
||||
expect.anything(),
|
||||
preTestDatabaseItem.databaseUri,
|
||||
);
|
||||
const childItems: TestItem[] = [
|
||||
{
|
||||
children: { size: 0 } as TestItemCollection,
|
||||
id: `test ${mockTestsInfo.dPath}`,
|
||||
uri: Uri.file(mockTestsInfo.dPath),
|
||||
} as TestItem,
|
||||
{
|
||||
children: { size: 0 } as TestItemCollection,
|
||||
id: `test ${mockTestsInfo.gPath}`,
|
||||
uri: Uri.file(mockTestsInfo.gPath),
|
||||
} as TestItem,
|
||||
{
|
||||
children: { size: 0 } as TestItemCollection,
|
||||
id: `test ${mockTestsInfo.hPath}`,
|
||||
uri: Uri.file(mockTestsInfo.hPath),
|
||||
} as TestItem,
|
||||
];
|
||||
const childElements: IdTestItemPair[] = childItems.map((childItem) => [
|
||||
childItem.id,
|
||||
childItem,
|
||||
]);
|
||||
const childIteratorFunc: () => Iterator<IdTestItemPair> = () =>
|
||||
childElements[Symbol.iterator]();
|
||||
|
||||
expect(renameDatabaseItemSpy).toBeCalledTimes(1);
|
||||
expect(renameDatabaseItemSpy).toBeCalledWith(
|
||||
postTestDatabaseItem,
|
||||
preTestDatabaseItem.name,
|
||||
);
|
||||
const rootItem = {
|
||||
id: `dir ${mockTestsInfo.testsPath}`,
|
||||
uri: Uri.file(mockTestsInfo.testsPath),
|
||||
children: {
|
||||
size: 3,
|
||||
[Symbol.iterator]: childIteratorFunc,
|
||||
} as TestItemCollection,
|
||||
} as TestItem;
|
||||
|
||||
expect(setCurrentDatabaseItemSpy).toBeCalledTimes(1);
|
||||
expect(setCurrentDatabaseItemSpy).toBeCalledWith(
|
||||
postTestDatabaseItem,
|
||||
true,
|
||||
const request = new TestRunRequest([rootItem]);
|
||||
await testManager.run(request, new CancellationTokenSource().token);
|
||||
|
||||
expect(enqueuedSpy).toBeCalledTimes(3);
|
||||
expect(passedSpy).toBeCalledTimes(1);
|
||||
expect(passedSpy).toHaveBeenCalledWith(childItems[0], 3000);
|
||||
expect(erroredSpy).toHaveBeenCalledTimes(1);
|
||||
expect(erroredSpy).toHaveBeenCalledWith(
|
||||
childItems[1],
|
||||
[
|
||||
{
|
||||
location: {
|
||||
range: new Range(0, 0, 1, 1),
|
||||
uri: Uri.file(mockTestsInfo.gPath),
|
||||
},
|
||||
message: "abc",
|
||||
},
|
||||
],
|
||||
4000,
|
||||
);
|
||||
expect(failedSpy).toHaveBeenCalledWith(
|
||||
childItems[2],
|
||||
[
|
||||
{
|
||||
message: "Test failed",
|
||||
},
|
||||
],
|
||||
11000,
|
||||
);
|
||||
expect(failedSpy).toBeCalledTimes(1);
|
||||
expect(endSpy).toBeCalledTimes(1);
|
||||
});
|
||||
|
||||
function mockRunTests() {
|
||||
// runTests is an async generator function. This is not directly supported in sinon
|
||||
// However, we can pretend the same thing by just returning an async array.
|
||||
runTestsSpy.mockReturnValue(
|
||||
(async function* () {
|
||||
yield Promise.resolve({
|
||||
test: Uri.parse("file:/ab/c/d.ql").fsPath,
|
||||
pass: true,
|
||||
messages: [],
|
||||
});
|
||||
yield Promise.resolve({
|
||||
test: Uri.parse("file:/ab/c/e/f/g.ql").fsPath,
|
||||
pass: false,
|
||||
diff: ["pqr", "xyz"],
|
||||
// a compile error
|
||||
failureStage: "COMPILATION",
|
||||
messages: [
|
||||
{ position: { line: 1 }, message: "abc", severity: "ERROR" },
|
||||
],
|
||||
});
|
||||
yield Promise.resolve({
|
||||
test: Uri.parse("file:/ab/c/e/f/h.ql").fsPath,
|
||||
pass: false,
|
||||
diff: ["jkh", "tuv"],
|
||||
failureStage: "RESULT",
|
||||
messages: [],
|
||||
});
|
||||
})(),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
import { Uri } from "vscode";
|
||||
import { mockedObject } from "../utils/mocking.helpers";
|
||||
import { CodeQLCliServer } from "../../../src/cli";
|
||||
import { DatabaseManager } from "../../../src/local-databases";
|
||||
|
||||
/**
|
||||
* Fake QL tests used by various tests.
|
||||
*/
|
||||
export const mockTestsInfo = {
|
||||
testsPath: Uri.parse("file:/ab/c").fsPath,
|
||||
dPath: Uri.parse("file:/ab/c/d.ql").fsPath,
|
||||
gPath: Uri.parse("file:/ab/c/e/f/g.ql").fsPath,
|
||||
hPath: Uri.parse("file:/ab/c/e/f/h.ql").fsPath,
|
||||
};
|
||||
|
||||
/**
|
||||
* Create a mock of a `DatabaseManager` with no databases loaded.
|
||||
*/
|
||||
export function mockEmptyDatabaseManager(): DatabaseManager {
|
||||
return mockedObject<DatabaseManager>({
|
||||
currentDatabaseItem: undefined,
|
||||
databaseItems: [],
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a `CodeQLCliServer` that "runs" the mock tests. Also returns the spy
|
||||
* hook for the `runTests` function on the CLI server.
|
||||
*/
|
||||
export function createMockCliServerForTestRun() {
|
||||
const resolveQlpacksSpy = jest.fn();
|
||||
resolveQlpacksSpy.mockResolvedValue({});
|
||||
|
||||
const resolveTestsSpy = jest.fn();
|
||||
resolveTestsSpy.mockResolvedValue([]);
|
||||
|
||||
const runTestsSpy = mockRunTests();
|
||||
return {
|
||||
cliServer: mockedObject<CodeQLCliServer>({
|
||||
runTests: runTestsSpy,
|
||||
resolveQlpacks: resolveQlpacksSpy,
|
||||
resolveTests: resolveTestsSpy,
|
||||
}),
|
||||
runTestsSpy,
|
||||
};
|
||||
}
|
||||
|
||||
function mockRunTests(): jest.Mock<any, any> {
|
||||
const runTestsSpy = jest.fn();
|
||||
// runTests is an async generator function. This is not directly supported in sinon
|
||||
// However, we can pretend the same thing by just returning an async array.
|
||||
runTestsSpy.mockReturnValue(
|
||||
(async function* () {
|
||||
yield Promise.resolve({
|
||||
test: mockTestsInfo.dPath,
|
||||
pass: true,
|
||||
messages: [],
|
||||
compilationMs: 1000,
|
||||
evaluationMs: 2000,
|
||||
});
|
||||
yield Promise.resolve({
|
||||
test: mockTestsInfo.gPath,
|
||||
pass: false,
|
||||
diff: ["pqr", "xyz"],
|
||||
// a compile error
|
||||
failureStage: "COMPILATION",
|
||||
compilationMs: 4000,
|
||||
evaluationMs: 0,
|
||||
messages: [
|
||||
{
|
||||
position: {
|
||||
fileName: mockTestsInfo.gPath,
|
||||
line: 1,
|
||||
column: 1,
|
||||
endLine: 2,
|
||||
endColumn: 2,
|
||||
},
|
||||
message: "abc",
|
||||
severity: "ERROR",
|
||||
},
|
||||
],
|
||||
});
|
||||
yield Promise.resolve({
|
||||
test: mockTestsInfo.hPath,
|
||||
pass: false,
|
||||
diff: ["jkh", "tuv"],
|
||||
failureStage: "RESULT",
|
||||
compilationMs: 5000,
|
||||
evaluationMs: 6000,
|
||||
messages: [],
|
||||
});
|
||||
})(),
|
||||
);
|
||||
|
||||
return runTestsSpy;
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
import { CancellationTokenSource, Uri } from "vscode";
|
||||
import { CodeQLCliServer } from "../../../src/cli";
|
||||
import {
|
||||
DatabaseItem,
|
||||
DatabaseItemImpl,
|
||||
DatabaseManager,
|
||||
FullDatabaseOptions,
|
||||
} from "../../../src/local-databases";
|
||||
import { mockedObject } from "../utils/mocking.helpers";
|
||||
import { TestRunner } from "../../../src/test-runner";
|
||||
import { createMockLogger } from "../../__mocks__/loggerMock";
|
||||
import {
|
||||
createMockCliServerForTestRun,
|
||||
mockTestsInfo,
|
||||
} from "./test-runner-helpers";
|
||||
|
||||
jest.mock("fs-extra", () => {
|
||||
const original = jest.requireActual("fs-extra");
|
||||
return {
|
||||
...original,
|
||||
access: jest.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
describe("test-runner", () => {
|
||||
let testRunner: TestRunner;
|
||||
let fakeDatabaseManager: DatabaseManager;
|
||||
let fakeCliServer: CodeQLCliServer;
|
||||
let currentDatabaseItem: DatabaseItem | undefined;
|
||||
let databaseItems: DatabaseItem[] = [];
|
||||
const openDatabaseSpy = jest.fn();
|
||||
const removeDatabaseItemSpy = jest.fn();
|
||||
const renameDatabaseItemSpy = jest.fn();
|
||||
const setCurrentDatabaseItemSpy = jest.fn();
|
||||
let runTestsSpy: jest.Mock<any, any>;
|
||||
const resolveTestsSpy = jest.fn();
|
||||
const resolveQlpacksSpy = jest.fn();
|
||||
|
||||
const preTestDatabaseItem = new DatabaseItemImpl(
|
||||
Uri.file("/path/to/test/dir/dir.testproj"),
|
||||
undefined,
|
||||
mockedObject<FullDatabaseOptions>({ displayName: "custom display name" }),
|
||||
(_) => {
|
||||
/* no change event listener */
|
||||
},
|
||||
);
|
||||
const postTestDatabaseItem = new DatabaseItemImpl(
|
||||
Uri.file("/path/to/test/dir/dir.testproj"),
|
||||
undefined,
|
||||
mockedObject<FullDatabaseOptions>({ displayName: "default name" }),
|
||||
(_) => {
|
||||
/* no change event listener */
|
||||
},
|
||||
);
|
||||
|
||||
beforeEach(() => {
|
||||
openDatabaseSpy.mockResolvedValue(postTestDatabaseItem);
|
||||
removeDatabaseItemSpy.mockResolvedValue(undefined);
|
||||
renameDatabaseItemSpy.mockResolvedValue(undefined);
|
||||
setCurrentDatabaseItemSpy.mockResolvedValue(undefined);
|
||||
resolveQlpacksSpy.mockResolvedValue({});
|
||||
resolveTestsSpy.mockResolvedValue([]);
|
||||
fakeDatabaseManager = mockedObject<DatabaseManager>(
|
||||
{
|
||||
openDatabase: openDatabaseSpy,
|
||||
removeDatabaseItem: removeDatabaseItemSpy,
|
||||
renameDatabaseItem: renameDatabaseItemSpy,
|
||||
setCurrentDatabaseItem: setCurrentDatabaseItemSpy,
|
||||
},
|
||||
{
|
||||
dynamicProperties: {
|
||||
currentDatabaseItem: () => currentDatabaseItem,
|
||||
databaseItems: () => databaseItems,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
jest.spyOn(preTestDatabaseItem, "isAffectedByTest").mockResolvedValue(true);
|
||||
|
||||
const mockCli = createMockCliServerForTestRun();
|
||||
fakeCliServer = mockCli.cliServer;
|
||||
runTestsSpy = mockCli.runTestsSpy;
|
||||
|
||||
testRunner = new TestRunner(fakeDatabaseManager, fakeCliServer);
|
||||
});
|
||||
|
||||
it("should run some tests", async () => {
|
||||
const eventHandlerSpy = jest.fn();
|
||||
|
||||
await testRunner.run(
|
||||
[mockTestsInfo.dPath, mockTestsInfo.gPath, mockTestsInfo.hPath],
|
||||
createMockLogger(),
|
||||
new CancellationTokenSource().token,
|
||||
eventHandlerSpy,
|
||||
);
|
||||
|
||||
expect(eventHandlerSpy).toBeCalledTimes(3);
|
||||
|
||||
expect(eventHandlerSpy).toHaveBeenNthCalledWith(1, {
|
||||
test: mockTestsInfo.dPath,
|
||||
pass: true,
|
||||
compilationMs: 1000,
|
||||
evaluationMs: 2000,
|
||||
messages: [],
|
||||
});
|
||||
expect(eventHandlerSpy).toHaveBeenNthCalledWith(2, {
|
||||
test: mockTestsInfo.gPath,
|
||||
pass: false,
|
||||
compilationMs: 4000,
|
||||
evaluationMs: 0,
|
||||
diff: ["pqr", "xyz"],
|
||||
failureStage: "COMPILATION",
|
||||
messages: [
|
||||
{
|
||||
message: "abc",
|
||||
position: {
|
||||
line: 1,
|
||||
column: 1,
|
||||
endLine: 2,
|
||||
endColumn: 2,
|
||||
fileName: mockTestsInfo.gPath,
|
||||
},
|
||||
severity: "ERROR",
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(eventHandlerSpy).toHaveBeenNthCalledWith(3, {
|
||||
test: mockTestsInfo.hPath,
|
||||
pass: false,
|
||||
compilationMs: 5000,
|
||||
evaluationMs: 6000,
|
||||
diff: ["jkh", "tuv"],
|
||||
failureStage: "RESULT",
|
||||
messages: [],
|
||||
});
|
||||
});
|
||||
|
||||
it("should reregister testproj databases around test run", async () => {
|
||||
currentDatabaseItem = preTestDatabaseItem;
|
||||
databaseItems = [preTestDatabaseItem];
|
||||
await testRunner.run(
|
||||
["/path/to/test/dir"],
|
||||
createMockLogger(),
|
||||
new CancellationTokenSource().token,
|
||||
async () => {
|
||||
/***/
|
||||
},
|
||||
);
|
||||
|
||||
expect(removeDatabaseItemSpy.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
runTestsSpy.mock.invocationCallOrder[0],
|
||||
);
|
||||
expect(openDatabaseSpy.mock.invocationCallOrder[0]).toBeGreaterThan(
|
||||
runTestsSpy.mock.invocationCallOrder[0],
|
||||
);
|
||||
expect(renameDatabaseItemSpy.mock.invocationCallOrder[0]).toBeGreaterThan(
|
||||
openDatabaseSpy.mock.invocationCallOrder[0],
|
||||
);
|
||||
expect(
|
||||
setCurrentDatabaseItemSpy.mock.invocationCallOrder[0],
|
||||
).toBeGreaterThan(openDatabaseSpy.mock.invocationCallOrder[0]);
|
||||
|
||||
expect(removeDatabaseItemSpy).toBeCalledTimes(1);
|
||||
expect(removeDatabaseItemSpy).toBeCalledWith(
|
||||
expect.anything(),
|
||||
expect.anything(),
|
||||
preTestDatabaseItem,
|
||||
);
|
||||
|
||||
expect(openDatabaseSpy).toBeCalledTimes(1);
|
||||
expect(openDatabaseSpy).toBeCalledWith(
|
||||
expect.anything(),
|
||||
expect.anything(),
|
||||
preTestDatabaseItem.databaseUri,
|
||||
);
|
||||
|
||||
expect(renameDatabaseItemSpy).toBeCalledTimes(1);
|
||||
expect(renameDatabaseItemSpy).toBeCalledWith(
|
||||
postTestDatabaseItem,
|
||||
preTestDatabaseItem.name,
|
||||
);
|
||||
|
||||
expect(setCurrentDatabaseItemSpy).toBeCalledTimes(1);
|
||||
expect(setCurrentDatabaseItemSpy).toBeCalledWith(
|
||||
postTestDatabaseItem,
|
||||
true,
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user