Merge remote-tracking branch 'origin/main' into koesie10/show-extension-pack-name

This commit is contained in:
Koen Vlaswinkel
2023-04-18 10:17:03 +02:00
42 changed files with 3939 additions and 4372 deletions

View File

@@ -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)

File diff suppressed because it is too large Load Diff

View File

@@ -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",

View File

@@ -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);

View File

@@ -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 &

View File

@@ -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;
}

View File

@@ -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, {

View File

@@ -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.");
}
}

View 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;
}
}
}

View 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;

View 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";
}
}

View 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),
);
}
}

View 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);
}
}

View File

@@ -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)) {

View File

@@ -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.

View File

@@ -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.",
);
}

View File

@@ -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",
},
);
}
/**

View File

@@ -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,

View File

@@ -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))
: [];
}
}

View File

@@ -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.

View File

@@ -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);
}
}

View File

@@ -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"),

View File

@@ -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,
})),
});
}
}

View 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);
}
}
}
}

View 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;
}
}

View 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;
}
}
}
}
}

View File

@@ -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;
}
}

View File

@@ -1 +1,2 @@
.vscode
.vscode/**
!.vscode/launch.json

View 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"
}
]
}

View 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);
});

View File

@@ -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
}
}

View File

@@ -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()

View File

@@ -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);
}
}

View File

@@ -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();
});
});
});

View File

@@ -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) {

View File

@@ -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) {

View File

@@ -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;
}

View File

@@ -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",
);
});
});
}

View 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: [],
});
})(),
);
}
});

View File

@@ -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;
}

View File

@@ -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,
);
});
});