diff --git a/extensions/ql-vscode/media/drive.svg b/extensions/ql-vscode/media/drive.svg new file mode 100644 index 000000000..48bd917dd --- /dev/null +++ b/extensions/ql-vscode/media/drive.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/extensions/ql-vscode/src/compare/compare-interface.ts b/extensions/ql-vscode/src/compare/compare-interface.ts index 50597e7d9..4cb14a21f 100644 --- a/extensions/ql-vscode/src/compare/compare-interface.ts +++ b/extensions/ql-vscode/src/compare/compare-interface.ts @@ -9,7 +9,6 @@ import { import * as path from 'path'; import { tmpDir } from '../run-queries'; -import { CompletedQuery } from '../query-results'; import { FromCompareViewMessage, ToCompareViewMessage, @@ -21,10 +20,11 @@ import { DatabaseManager } from '../databases'; import { getHtmlForWebview, jumpToLocation } from '../interface-utils'; import { transformBqrsResultSet, RawResultSet, BQRSInfo } from '../pure/bqrs-cli-types'; import resultsDiff from './resultsDiff'; +import { FullCompletedQueryInfo } from '../query-results'; interface ComparePair { - from: CompletedQuery; - to: CompletedQuery; + from: FullCompletedQueryInfo; + to: FullCompletedQueryInfo; } export class CompareInterfaceManager extends DisposableObject { @@ -39,15 +39,15 @@ export class CompareInterfaceManager extends DisposableObject { private cliServer: CodeQLCliServer, private logger: Logger, private showQueryResultsCallback: ( - item: CompletedQuery + item: FullCompletedQueryInfo ) => Promise ) { super(); } async showResults( - from: CompletedQuery, - to: CompletedQuery, + from: FullCompletedQueryInfo, + to: FullCompletedQueryInfo, selectedResultSetName?: string ) { this.comparePair = { from, to }; @@ -80,17 +80,13 @@ export class CompareInterfaceManager extends DisposableObject { // since we split the description into several rows // only run interpolation if the label is user-defined // otherwise we will wind up with duplicated rows - name: from.options.label - ? from.interpolate(from.getLabel()) - : from.queryName, - status: from.statusString, + name: from.getShortLabel(), + status: from.completedQuery.statusString, time: from.time, }, toQuery: { - name: to.options.label - ? to.interpolate(to.getLabel()) - : to.queryName, - status: to.statusString, + name: to.getShortLabel(), + status: to.completedQuery.statusString, time: to.time, }, }, @@ -99,7 +95,7 @@ export class CompareInterfaceManager extends DisposableObject { currentResultSetName: currentResultSetName, rows, message, - datebaseUri: to.database.databaseUri, + datebaseUri: to.initialInfo.databaseInfo.databaseUri, }); } } @@ -121,14 +117,14 @@ export class CompareInterfaceManager extends DisposableObject { ], } )); - this.panel.onDidDispose( + this.push(this.panel.onDidDispose( () => { this.panel = undefined; this.comparePair = undefined; }, null, ctx.subscriptions - ); + )); const scriptPathOnDisk = Uri.file( ctx.asAbsolutePath('out/compareView.js') @@ -143,11 +139,11 @@ export class CompareInterfaceManager extends DisposableObject { scriptPathOnDisk, [stylesheetPathOnDisk] ); - panel.webview.onDidReceiveMessage( + this.push(panel.webview.onDidReceiveMessage( async (e) => this.handleMsgFromView(e), undefined, ctx.subscriptions - ); + )); } return this.panel; } @@ -191,15 +187,15 @@ export class CompareInterfaceManager extends DisposableObject { } private async findCommonResultSetNames( - from: CompletedQuery, - to: CompletedQuery, + from: FullCompletedQueryInfo, + to: FullCompletedQueryInfo, selectedResultSetName: string | undefined ): Promise<[string[], string, RawResultSet, RawResultSet]> { const fromSchemas = await this.cliServer.bqrsInfo( - from.query.resultsPaths.resultsPath + from.completedQuery.query.resultsPaths.resultsPath ); const toSchemas = await this.cliServer.bqrsInfo( - to.query.resultsPaths.resultsPath + to.completedQuery.query.resultsPaths.resultsPath ); const fromSchemaNames = fromSchemas['result-sets'].map( (schema) => schema.name @@ -215,12 +211,12 @@ export class CompareInterfaceManager extends DisposableObject { const fromResultSet = await this.getResultSet( fromSchemas, currentResultSetName, - from.query.resultsPaths.resultsPath + from.completedQuery.query.resultsPaths.resultsPath ); const toResultSet = await this.getResultSet( toSchemas, currentResultSetName, - to.query.resultsPaths.resultsPath + to.completedQuery.query.resultsPaths.resultsPath ); return [ commonResultSetNames, diff --git a/extensions/ql-vscode/src/contextual/locationFinder.ts b/extensions/ql-vscode/src/contextual/locationFinder.ts index 057b69813..a331ac97f 100644 --- a/extensions/ql-vscode/src/contextual/locationFinder.ts +++ b/extensions/ql-vscode/src/contextual/locationFinder.ts @@ -1,5 +1,3 @@ -import * as vscode from 'vscode'; - import { decodeSourceArchiveUri, encodeArchiveBasePath } from '../archive-filesystem-provider'; import { ColumnKindCode, EntityValue, getResultSetSchema, ResultSetSchema } from '../pure/bqrs-cli-types'; import { CodeQLCliServer } from '../cli'; @@ -7,16 +5,17 @@ import { DatabaseManager, DatabaseItem } from '../databases'; import fileRangeFromURI from './fileRangeFromURI'; import * as messages from '../pure/messages'; import { QueryServerClient } from '../queryserver-client'; -import { QueryWithResults, compileAndRunQueryAgainstDatabase } from '../run-queries'; +import { QueryWithResults, compileAndRunQueryAgainstDatabase, createInitialQueryInfo } from '../run-queries'; import { ProgressCallback } from '../commandRunner'; import { KeyType } from './keyType'; import { qlpackOfDatabase, resolveQueries } from './queryResolver'; +import { CancellationToken, LocationLink, Uri } from 'vscode'; export const SELECT_QUERY_NAME = '#select'; export const TEMPLATE_NAME = 'selectedSourceFile'; -export interface FullLocationLink extends vscode.LocationLink { - originUri: vscode.Uri; +export interface FullLocationLink extends LocationLink { + originUri: Uri; } /** @@ -40,10 +39,10 @@ export async function getLocationsForUriString( uriString: string, keyType: KeyType, progress: ProgressCallback, - token: vscode.CancellationToken, + token: CancellationToken, filter: (src: string, dest: string) => boolean ): Promise { - const uri = decodeSourceArchiveUri(vscode.Uri.parse(uriString, true)); + const uri = decodeSourceArchiveUri(Uri.parse(uriString, true)); const sourceArchiveUri = encodeArchiveBasePath(uri.sourceArchiveZipPath); const db = dbm.findDatabaseItemBySourceArchive(sourceArchiveUri); @@ -56,12 +55,20 @@ export async function getLocationsForUriString( const links: FullLocationLink[] = []; for (const query of await resolveQueries(cli, qlpack, keyType)) { + const initialInfo = await createInitialQueryInfo( + Uri.file(query), + { + name: db.name, + databaseUri: db.databaseUri.toString(), + }, + false + ); + const results = await compileAndRunQueryAgainstDatabase( cli, qs, db, - false, - vscode.Uri.file(query), + initialInfo, progress, token, templates diff --git a/extensions/ql-vscode/src/contextual/templateProvider.ts b/extensions/ql-vscode/src/contextual/templateProvider.ts index 8da041f46..69ba4c49d 100644 --- a/extensions/ql-vscode/src/contextual/templateProvider.ts +++ b/extensions/ql-vscode/src/contextual/templateProvider.ts @@ -18,7 +18,7 @@ import { CachedOperation } from '../helpers'; import { ProgressCallback, withProgress } from '../commandRunner'; import * as messages from '../pure/messages'; import { QueryServerClient } from '../queryserver-client'; -import { compileAndRunQueryAgainstDatabase, QueryWithResults } from '../run-queries'; +import { compileAndRunQueryAgainstDatabase, createInitialQueryInfo, QueryWithResults } from '../run-queries'; import AstBuilder from './astBuilder'; import { KeyType, @@ -123,15 +123,20 @@ export class TemplateQueryReferenceProvider implements ReferenceProvider { } } +type QueryWithDb = { + query: QueryWithResults, + dbUri: Uri +}; + export class TemplatePrintAstProvider { - private cache: CachedOperation; + private cache: CachedOperation; constructor( private cli: CodeQLCliServer, private qs: QueryServerClient, private dbm: DatabaseManager, ) { - this.cache = new CachedOperation(this.getAst.bind(this)); + this.cache = new CachedOperation(this.getAst.bind(this)); } async provideAst( @@ -142,13 +147,13 @@ export class TemplatePrintAstProvider { if (!document) { throw new Error('Cannot view the AST. Please select a valid source file inside a CodeQL database.'); } - const queryResults = this.shouldCache() + const { query, dbUri } = this.shouldCache() ? await this.cache.get(document.uri.toString(), progress, token) : await this.getAst(document.uri.toString(), progress, token); return new AstBuilder( - queryResults, this.cli, - this.dbm.findDatabaseItem(Uri.parse(queryResults.database.databaseUri!, true))!, + query, this.cli, + this.dbm.findDatabaseItem(dbUri)!, document.fileName ); } @@ -161,7 +166,7 @@ export class TemplatePrintAstProvider { uriString: string, progress: ProgressCallback, token: CancellationToken - ): Promise { + ): Promise { const uri = Uri.parse(uriString, true); if (uri.scheme !== zipArchiveScheme) { throw new Error('Cannot view the AST. Please select a valid source file inside a CodeQL database.'); @@ -195,15 +200,26 @@ export class TemplatePrintAstProvider { } }; - return await compileAndRunQueryAgainstDatabase( - this.cli, - this.qs, - db, - false, + const initialInfo = await createInitialQueryInfo( Uri.file(query), - progress, - token, - templates + { + name: db.name, + databaseUri: db.databaseUri.toString(), + }, + false ); + + return { + query: await compileAndRunQueryAgainstDatabase( + this.cli, + this.qs, + db, + initialInfo, + progress, + token, + templates + ), + dbUri: db.databaseUri + }; } } diff --git a/extensions/ql-vscode/src/extension.ts b/extensions/ql-vscode/src/extension.ts index c1277a864..14080c718 100644 --- a/extensions/ql-vscode/src/extension.ts +++ b/extensions/ql-vscode/src/extension.ts @@ -59,10 +59,10 @@ import { InterfaceManager } from './interface'; import { WebviewReveal } from './interface-utils'; import { ideServerLogger, logger, queryServerLogger } from './logging'; import { QueryHistoryManager } from './query-history'; -import { CompletedQuery } from './query-results'; +import { FullCompletedQueryInfo, FullQueryInfo } from './query-results'; import * as qsClient from './queryserver-client'; import { displayQuickQuery } from './quick-query'; -import { compileAndRunQueryAgainstDatabase, tmpDirDisposal } from './run-queries'; +import { compileAndRunQueryAgainstDatabase, createInitialQueryInfo, tmpDirDisposal } from './run-queries'; import { QLTestAdapterFactory } from './test-adapter'; import { TestUIService } from './test-ui'; import { CompareInterfaceManager } from './compare/compare-interface'; @@ -432,7 +432,7 @@ async function activateWithInstalledDistribution( void logger.log('Initializing query history manager.'); const queryHistoryConfigurationListener = new QueryHistoryConfigListener(); ctx.subscriptions.push(queryHistoryConfigurationListener); - const showResults = async (item: CompletedQuery) => + const showResults = async (item: FullCompletedQueryInfo) => showResultsForCompletedQuery(item, WebviewReveal.Forced); const qhm = new QueryHistoryManager( @@ -440,7 +440,7 @@ async function activateWithInstalledDistribution( ctx.extensionPath, queryHistoryConfigurationListener, showResults, - async (from: CompletedQuery, to: CompletedQuery) => + async (from: FullCompletedQueryInfo, to: FullCompletedQueryInfo) => showResultsForComparison(from, to), ); ctx.subscriptions.push(qhm); @@ -462,8 +462,8 @@ async function activateWithInstalledDistribution( archiveFilesystemProvider.activate(ctx); async function showResultsForComparison( - from: CompletedQuery, - to: CompletedQuery + from: FullCompletedQueryInfo, + to: FullCompletedQueryInfo ): Promise { try { await cmpm.showResults(from, to); @@ -473,7 +473,7 @@ async function activateWithInstalledDistribution( } async function showResultsForCompletedQuery( - query: CompletedQuery, + query: FullCompletedQueryInfo, forceReveal: WebviewReveal ): Promise { await intm.showResults(query, forceReveal, false); @@ -493,22 +493,35 @@ async function activateWithInstalledDistribution( if (databaseItem === undefined) { throw new Error('Can\'t run query without a selected database'); } - const info = await compileAndRunQueryAgainstDatabase( - cliServer, - qs, - databaseItem, - quickEval, - selectedQuery, - progress, - token, - undefined, - range - ); - const item = qhm.buildCompletedQuery(info); - await showResultsForCompletedQuery(item, WebviewReveal.NotForced); - // Note we must update the query history view after showing results as the - // display and sorting might depend on the number of results - await qhm.addCompletedQuery(item); + const databaseInfo = { + name: databaseItem.name, + databaseUri: databaseItem.databaseUri.toString(), + }; + + const initialInfo = await createInitialQueryInfo(selectedQuery, databaseInfo, quickEval, range); + const item = new FullQueryInfo(initialInfo, queryHistoryConfigurationListener); + qhm.addCompletedQuery(item); + await qhm.refreshTreeView(item); + + try { + const info = await compileAndRunQueryAgainstDatabase( + cliServer, + qs, + databaseItem, + initialInfo, + progress, + token, + ); + item.completeThisQuery(info); + await showResultsForCompletedQuery(item as FullCompletedQueryInfo, WebviewReveal.NotForced); + // Note we must update the query history view after showing results as the + // display and sorting might depend on the number of results + } catch (e) { + item.failureReason = e.message; + throw e; + } finally { + await qhm.refreshTreeView(item); + } } } @@ -1027,7 +1040,7 @@ const checkForUpdatesCommand = 'codeQL.checkForUpdatesToCLI'; /** * This text provider lets us open readonly files in the editor. - * + * * TODO: Consolidate this with the 'codeql' text provider in query-history.ts. */ function registerRemoteQueryTextProvider() { diff --git a/extensions/ql-vscode/src/interface.ts b/extensions/ql-vscode/src/interface.ts index eb40ee9b0..16b2a5a2f 100644 --- a/extensions/ql-vscode/src/interface.ts +++ b/extensions/ql-vscode/src/interface.ts @@ -32,8 +32,8 @@ import { import { Logger } from './logging'; import * as messages from './pure/messages'; import { commandRunner } from './commandRunner'; -import { CompletedQuery, interpretResults } from './query-results'; -import { QueryInfo, tmpDir } from './run-queries'; +import { CompletedQueryInfo, interpretResults } from './query-results'; +import { QueryEvaluatonInfo, tmpDir } from './run-queries'; import { parseSarifLocation, parseSarifPlainTextMessage } from './pure/sarif-utils'; import { WebviewReveal, @@ -47,6 +47,7 @@ import { import { getDefaultResultSetName, ParsedResultSets } from './pure/interface-types'; import { RawResultSet, transformBqrsResultSet, ResultSetSchema } from './pure/bqrs-cli-types'; import { PAGE_SIZE } from './config'; +import { FullCompletedQueryInfo } from './query-results'; /** * interface.ts @@ -96,7 +97,7 @@ function numInterpretedPages(interpretation: Interpretation | undefined): number } export class InterfaceManager extends DisposableObject { - private _displayedQuery?: CompletedQuery; + private _displayedQuery?: FullCompletedQueryInfo; private _interpretation?: Interpretation; private _panel: vscode.WebviewPanel | undefined; private _panelLoaded = false; @@ -176,14 +177,14 @@ export class InterfaceManager extends DisposableObject { } )); - this._panel.onDidDispose( + this.push(this._panel.onDidDispose( () => { this._panel = undefined; this._displayedQuery = undefined; }, null, ctx.subscriptions - ); + )); const scriptPathOnDisk = vscode.Uri.file( ctx.asAbsolutePath('out/resultsView.js') ); @@ -195,11 +196,11 @@ export class InterfaceManager extends DisposableObject { scriptPathOnDisk, [stylesheetPathOnDisk] ); - panel.webview.onDidReceiveMessage( + this.push(panel.webview.onDidReceiveMessage( async (e) => this.handleMsgFromView(e), undefined, ctx.subscriptions - ); + )); } return this._panel; } @@ -238,7 +239,7 @@ export class InterfaceManager extends DisposableObject { } // Notify the webview that it should expect new results. await this.postMessage({ t: 'resultsUpdating' }); - await this._displayedQuery.updateInterpretedSortState(sortState); + await this._displayedQuery.completedQuery.updateInterpretedSortState(sortState); await this.showResults(this._displayedQuery, WebviewReveal.NotForced, true); } @@ -254,7 +255,7 @@ export class InterfaceManager extends DisposableObject { } // Notify the webview that it should expect new results. await this.postMessage({ t: 'resultsUpdating' }); - await this._displayedQuery.updateSortState( + await this._displayedQuery.completedQuery.updateSortState( this.cliServer, resultSetName, sortState @@ -314,7 +315,7 @@ export class InterfaceManager extends DisposableObject { // sortedResultsInfo doesn't have an entry for the current // result set. Use this to determine whether or not we use // the sorted bqrs file. - this._displayedQuery?.sortedResultsInfo.has(msg.selectedTable) || false + this._displayedQuery?.completedQuery.sortedResultsInfo.has(msg.selectedTable) || false ); } break; @@ -347,7 +348,7 @@ export class InterfaceManager extends DisposableObject { /** * Show query results in webview panel. - * @param results Evaluation info for the executed query. + * @param fullQuery Evaluation info for the executed query. * @param shouldKeepOldResultsWhileRendering Should keep old results while rendering. * @param forceReveal Force the webview panel to be visible and * Appropriate when the user has just performed an explicit @@ -355,27 +356,27 @@ export class InterfaceManager extends DisposableObject { * history entry. */ public async showResults( - results: CompletedQuery, + fullQuery: FullCompletedQueryInfo, forceReveal: WebviewReveal, shouldKeepOldResultsWhileRendering = false ): Promise { - if (results.result.resultType !== messages.QueryResultType.SUCCESS) { + if (fullQuery.completedQuery.result.resultType !== messages.QueryResultType.SUCCESS) { return; } this._interpretation = undefined; const interpretationPage = await this.interpretResultsInfo( - results.query, - results.interpretedResultsSortState + fullQuery.completedQuery.query, + fullQuery.completedQuery.interpretedResultsSortState ); const sortedResultsMap: SortedResultsMap = {}; - results.sortedResultsInfo.forEach( + fullQuery.completedQuery.sortedResultsInfo.forEach( (v, k) => (sortedResultsMap[k] = this.convertPathPropertiesToWebviewUris(v)) ); - this._displayedQuery = results; + this._displayedQuery = fullQuery; const panel = this.getPanel(); await this.waitForPanelLoaded(); @@ -388,7 +389,7 @@ export class InterfaceManager extends DisposableObject { // more asynchronous message to not so abruptly interrupt // user's workflow by immediately revealing the panel. const showButton = 'View Results'; - const queryName = results.queryName; + const queryName = fullQuery.getShortLabel(); const resultPromise = vscode.window.showInformationMessage( `Finished running query ${queryName.length > 0 ? ` "${queryName}"` : '' }.`, @@ -407,7 +408,7 @@ export class InterfaceManager extends DisposableObject { // Note that the resultSetSchemas will return offsets for the default (unsorted) page, // which may not be correct. However, in this case, it doesn't matter since we only // need the first offset, which will be the same no matter which sorting we use. - const resultSetSchemas = await this.getResultSetSchemas(results); + const resultSetSchemas = await this.getResultSetSchemas(fullQuery.completedQuery); const resultSetNames = resultSetSchemas.map(schema => schema.name); const selectedTable = getDefaultResultSetName(resultSetNames); @@ -417,7 +418,7 @@ export class InterfaceManager extends DisposableObject { // Use sorted results path if it exists. This may happen if we are // reloading the results view after it has been sorted in the past. - const resultsPath = results.getResultsPath(selectedTable); + const resultsPath = fullQuery.completedQuery.getResultsPath(selectedTable); const pageSize = PAGE_SIZE.getValue(); const chunk = await this.cliServer.bqrsDecode( resultsPath, @@ -432,7 +433,7 @@ export class InterfaceManager extends DisposableObject { } ); const resultSet = transformBqrsResultSet(schema, chunk); - results.setResultCount(interpretationPage?.numTotalResults || resultSet.schema.rows); + fullQuery.completedQuery.setResultCount(interpretationPage?.numTotalResults || resultSet.schema.rows); const parsedResultSets: ParsedResultSets = { pageNumber: 0, pageSize, @@ -446,17 +447,17 @@ export class InterfaceManager extends DisposableObject { await this.postMessage({ t: 'setState', interpretation: interpretationPage, - origResultsPaths: results.query.resultsPaths, + origResultsPaths: fullQuery.completedQuery.query.resultsPaths, resultsPath: this.convertPathToWebviewUri( - results.query.resultsPaths.resultsPath + fullQuery.completedQuery.query.resultsPaths.resultsPath ), parsedResultSets, sortedResultsMap, - database: results.database, + database: fullQuery.initialInfo.databaseInfo, shouldKeepOldResultsWhileRendering, - metadata: results.query.metadata, - queryName: results.toString(), - queryPath: results.query.program.queryPath + metadata: fullQuery.completedQuery.query.metadata, + queryName: fullQuery.label, + queryPath: fullQuery.completedQuery.query.program.queryPath }); } @@ -476,25 +477,25 @@ export class InterfaceManager extends DisposableObject { throw new Error('Trying to show interpreted results but results were undefined'); } - const resultSetSchemas = await this.getResultSetSchemas(this._displayedQuery); + const resultSetSchemas = await this.getResultSetSchemas(this._displayedQuery.completedQuery); const resultSetNames = resultSetSchemas.map(schema => schema.name); await this.postMessage({ t: 'showInterpretedPage', interpretation: this.getPageOfInterpretedResults(pageNumber), - database: this._displayedQuery.database, - metadata: this._displayedQuery.query.metadata, + database: this._displayedQuery.initialInfo.databaseInfo, + metadata: this._displayedQuery.completedQuery.query.metadata, pageNumber, resultSetNames, pageSize: PAGE_SIZE.getValue(), numPages: numInterpretedPages(this._interpretation), - queryName: this._displayedQuery.toString(), - queryPath: this._displayedQuery.query.program.queryPath + queryName: this._displayedQuery.label, + queryPath: this._displayedQuery.completedQuery.query.program.queryPath }); } - private async getResultSetSchemas(results: CompletedQuery, selectedTable = ''): Promise { - const resultsPath = results.getResultsPath(selectedTable); + private async getResultSetSchemas(completedQuery: CompletedQueryInfo, selectedTable = ''): Promise { + const resultsPath = completedQuery.getResultsPath(selectedTable); const schemas = await this.cliServer.bqrsInfo( resultsPath, PAGE_SIZE.getValue() @@ -521,17 +522,17 @@ export class InterfaceManager extends DisposableObject { } const sortedResultsMap: SortedResultsMap = {}; - results.sortedResultsInfo.forEach( + results.completedQuery.sortedResultsInfo.forEach( (v, k) => (sortedResultsMap[k] = this.convertPathPropertiesToWebviewUris(v)) ); - const resultSetSchemas = await this.getResultSetSchemas(results, sorted ? selectedTable : ''); + const resultSetSchemas = await this.getResultSetSchemas(results.completedQuery, sorted ? selectedTable : ''); // If there is a specific sorted table selected, a different bqrs file is loaded that doesn't have all the result set names. // Make sure that we load all result set names here. // See https://github.com/github/vscode-codeql/issues/1005 - const allResultSetSchemas = sorted ? await this.getResultSetSchemas(results, '') : resultSetSchemas; + const allResultSetSchemas = sorted ? await this.getResultSetSchemas(results.completedQuery, '') : resultSetSchemas; const resultSetNames = allResultSetSchemas.map(schema => schema.name); const schema = resultSetSchemas.find( @@ -542,7 +543,7 @@ export class InterfaceManager extends DisposableObject { const pageSize = PAGE_SIZE.getValue(); const chunk = await this.cliServer.bqrsDecode( - results.getResultsPath(selectedTable, sorted), + results.completedQuery.getResultsPath(selectedTable, sorted), schema.name, { offset: schema.pagination?.offsets[pageNumber], @@ -564,17 +565,17 @@ export class InterfaceManager extends DisposableObject { await this.postMessage({ t: 'setState', interpretation: this._interpretation, - origResultsPaths: results.query.resultsPaths, + origResultsPaths: results.completedQuery.query.resultsPaths, resultsPath: this.convertPathToWebviewUri( - results.query.resultsPaths.resultsPath + results.completedQuery.query.resultsPaths.resultsPath ), parsedResultSets, sortedResultsMap, - database: results.database, + database: results.initialInfo.databaseInfo, shouldKeepOldResultsWhileRendering: false, - metadata: results.query.metadata, - queryName: results.toString(), - queryPath: results.query.program.queryPath + metadata: results.completedQuery.query.metadata, + queryName: results.label, + queryPath: results.completedQuery.query.program.queryPath }); } @@ -643,7 +644,7 @@ export class InterfaceManager extends DisposableObject { } private async interpretResultsInfo( - query: QueryInfo, + query: QueryEvaluatonInfo, sortState: InterpretedResultsSortState | undefined ): Promise { if ( diff --git a/extensions/ql-vscode/src/logging.ts b/extensions/ql-vscode/src/logging.ts index c1b31bfc3..70d8ad8bb 100644 --- a/extensions/ql-vscode/src/logging.ts +++ b/extensions/ql-vscode/src/logging.ts @@ -74,31 +74,39 @@ export class OutputChannelLogger extends DisposableObject implements Logger { * continuing. */ async log(message: string, options = {} as LogOptions): Promise { - if (options.trailingNewline === undefined) { - options.trailingNewline = true; - } - - if (options.trailingNewline) { - this.outputChannel.appendLine(message); - } else { - this.outputChannel.append(message); - } - - if (this.additionalLogLocationPath && options.additionalLogLocation) { - const logPath = path.join(this.additionalLogLocationPath, options.additionalLogLocation); - let additional = this.additionalLocations.get(logPath); - if (!additional) { - const msg = `| Log being saved to ${logPath} |`; - const separator = new Array(msg.length).fill('-').join(''); - this.outputChannel.appendLine(separator); - this.outputChannel.appendLine(msg); - this.outputChannel.appendLine(separator); - additional = new AdditionalLogLocation(logPath, !this.isCustomLogDirectory); - this.additionalLocations.set(logPath, additional); - this.track(additional); + try { + if (options.trailingNewline === undefined) { + options.trailingNewline = true; + } + if (options.trailingNewline) { + this.outputChannel.appendLine(message); + } else { + this.outputChannel.append(message); } - await additional.log(message, options); + if (this.additionalLogLocationPath && options.additionalLogLocation) { + const logPath = path.join(this.additionalLogLocationPath, options.additionalLogLocation); + let additional = this.additionalLocations.get(logPath); + if (!additional) { + const msg = `| Log being saved to ${logPath} |`; + const separator = new Array(msg.length).fill('-').join(''); + this.outputChannel.appendLine(separator); + this.outputChannel.appendLine(msg); + this.outputChannel.appendLine(separator); + additional = new AdditionalLogLocation(logPath, !this.isCustomLogDirectory); + this.additionalLocations.set(logPath, additional); + this.track(additional); + } + + await additional.log(message, options); + } + } catch (e) { + if (e instanceof Error && e.message === 'Channel has been closed') { + // Output channel is closed logging to console instead + console.log('Output channel is closed logging to console instead:', message); + } else { + throw e; + } } } diff --git a/extensions/ql-vscode/src/query-history.ts b/extensions/ql-vscode/src/query-history.ts index cd097ffa8..cc016cd19 100644 --- a/extensions/ql-vscode/src/query-history.ts +++ b/extensions/ql-vscode/src/query-history.ts @@ -1,9 +1,20 @@ import * as path from 'path'; -import * as vscode from 'vscode'; -import { window as Window, env } from 'vscode'; -import { CompletedQuery } from './query-results'; +import { + commands, + env, + Event, + EventEmitter, + ProviderResult, + Range, + ThemeIcon, + TreeItem, + TreeView, + Uri, + ViewColumn, + window, + workspace, +} from 'vscode'; import { QueryHistoryConfig } from './config'; -import { QueryWithResults } from './run-queries'; import { showAndLogErrorMessage, showAndLogInformationMessage, @@ -16,6 +27,7 @@ import { QueryServerClient } from './queryserver-client'; import { DisposableObject } from './pure/disposable-object'; import { commandRunner } from './commandRunner'; import { assertNever } from './pure/helpers-pure'; +import { FullCompletedQueryInfo, FullQueryInfo, QueryStatus } from './query-results'; /** * query-history.ts @@ -26,12 +38,6 @@ import { assertNever } from './pure/helpers-pure'; * `TreeDataProvider` subclass below. */ -export type QueryHistoryItemOptions = { - label?: string; // user-settable label - queryText?: string; // text of the selected file - isQuickQuery?: boolean; -}; - export const SHOW_QUERY_TEXT_MSG = `\ //////////////////////////////////////////////////////////////////////////////////// // This is the text of the entire query file when it was executed for this query // @@ -59,6 +65,11 @@ const SHOW_QUERY_TEXT_QUICK_EVAL_MSG = `\ */ const FAILED_QUERY_HISTORY_ITEM_ICON = 'media/red-x.svg'; +/** + * Path to icon to display next to a successful local run. + */ +const LOCAL_SUCCESS_QUERY_HISTORY_ITEM_ICON = 'media/drive.svg'; + enum SortOrder { NameAsc = 'NameAsc', NameDesc = 'NameDesc', @@ -74,19 +85,21 @@ enum SortOrder { export class HistoryTreeDataProvider extends DisposableObject { private _sortOrder = SortOrder.DateAsc; - private _onDidChangeTreeData = super.push(new vscode.EventEmitter()); + private _onDidChangeTreeData = super.push(new EventEmitter()); - readonly onDidChangeTreeData: vscode.Event = this + readonly onDidChangeTreeData: Event = this ._onDidChangeTreeData.event; - private history: CompletedQuery[] = []; + private history: FullQueryInfo[] = []; private failedIconPath: string; + private localSuccessIconPath: string; + /** * When not undefined, must be reference-equal to an item in `this.databases`. */ - private current: CompletedQuery | undefined; + private current: FullQueryInfo | undefined; constructor(extensionPath: string) { super(); @@ -94,10 +107,14 @@ export class HistoryTreeDataProvider extends DisposableObject { extensionPath, FAILED_QUERY_HISTORY_ITEM_ICON ); + this.localSuccessIconPath = path.join( + extensionPath, + LOCAL_SUCCESS_QUERY_HISTORY_ITEM_ICON + ); } - async getTreeItem(element: CompletedQuery): Promise { - const treeItem = new vscode.TreeItem(element.toString()); + async getTreeItem(element: FullQueryInfo): Promise { + const treeItem = new TreeItem(element.label); treeItem.command = { title: 'Query History Item', @@ -108,61 +125,77 @@ export class HistoryTreeDataProvider extends DisposableObject { // Mark this query history item according to whether it has a // SARIF file so that we can make context menu items conditionally // available. - const hasResults = await element.query.hasInterpretedResults(); + const hasResults = await element.completedQuery?.query.hasInterpretedResults(); treeItem.contextValue = hasResults ? 'interpretedResultsItem' : 'rawResultsItem'; - if (!element.didRunSuccessfully) { - treeItem.iconPath = this.failedIconPath; + switch (element.status) { + case QueryStatus.InProgress: + // TODO this is not a good icon. + treeItem.iconPath = new ThemeIcon('sync~spin'); + break; + case QueryStatus.Completed: + treeItem.iconPath = this.localSuccessIconPath; + break; + case QueryStatus.Failed: + treeItem.iconPath = this.failedIconPath; + break; + default: + assertNever(element.status); } return treeItem; } getChildren( - element?: CompletedQuery - ): vscode.ProviderResult { - return element ? [] : this.history.sort((q1, q2) => { + element?: FullQueryInfo + ): ProviderResult { + return element ? [] : this.history.sort((h1, h2) => { + const q1 = h1.completedQuery; + const q2 = h2.completedQuery; + switch (this.sortOrder) { case SortOrder.NameAsc: - return q1.toString().localeCompare(q2.toString(), env.language); + return h1.label.localeCompare(h2.label, env.language); case SortOrder.NameDesc: - return q2.toString().localeCompare(q1.toString(), env.language); + return h2.label.localeCompare(h1.label, env.language); case SortOrder.DateAsc: - return q1.date.getTime() - q2.date.getTime(); + return h1.initialInfo.start.getTime() - h2.initialInfo.start.getTime(); case SortOrder.DateDesc: - return q2.date.getTime() - q1.date.getTime(); + return h2.initialInfo.start.getTime() - h1.initialInfo.start.getTime(); case SortOrder.CountAsc: - return q1.resultCount - q2.resultCount; + return (!q1 || !q2) ? 0 : q1.resultCount - q2.resultCount; case SortOrder.CountDesc: - return q2.resultCount - q1.resultCount; + return (!q1 || !q2) ? 0 : q2.resultCount - q1.resultCount; default: assertNever(this.sortOrder); } }); } - getParent(_element: CompletedQuery): vscode.ProviderResult { + getParent(_element: FullQueryInfo): ProviderResult { return null; } - getCurrent(): CompletedQuery | undefined { + getCurrent(): FullQueryInfo | undefined { return this.current; } - pushQuery(item: CompletedQuery): void { + pushQuery(item: FullQueryInfo): void { this.current = item; this.history.push(item); this.refresh(); } - setCurrentItem(item: CompletedQuery) { + setCurrentItem(item?: FullQueryInfo) { this.current = item; } - remove(item: CompletedQuery) { - if (this.current === item) this.current = undefined; + remove(item: FullQueryInfo) { + if (this.current === item) { + this.current = undefined; + } const index = this.history.findIndex((i) => i === item); if (index >= 0) { this.history.splice(index, 1); @@ -175,16 +208,12 @@ export class HistoryTreeDataProvider extends DisposableObject { } } - get allHistory(): CompletedQuery[] { + get allHistory(): FullQueryInfo[] { return this.history; } - refresh(completedQuery?: CompletedQuery) { - this._onDidChangeTreeData.fire(completedQuery); - } - - find(queryId: number): CompletedQuery | undefined { - return this.allHistory.find((query) => query.query.queryID === queryId); + refresh(item?: FullQueryInfo) { + this._onDidChangeTreeData.fire(item); } public get sortOrder() { @@ -204,33 +233,32 @@ export class HistoryTreeDataProvider extends DisposableObject { const DOUBLE_CLICK_TIME = 500; const NO_QUERY_SELECTED = 'No query selected. Select a query history item you have already run and try again.'; + export class QueryHistoryManager extends DisposableObject { treeDataProvider: HistoryTreeDataProvider; - treeView: vscode.TreeView; - lastItemClick: { time: Date; item: CompletedQuery } | undefined; - compareWithItem: CompletedQuery | undefined; + treeView: TreeView; + lastItemClick: { time: Date; item: FullQueryInfo } | undefined; + compareWithItem: FullQueryInfo | undefined; constructor( private qs: QueryServerClient, extensionPath: string, - private queryHistoryConfigListener: QueryHistoryConfig, - private selectedCallback: (item: CompletedQuery) => Promise, + queryHistoryConfigListener: QueryHistoryConfig, + private selectedCallback: (item: FullCompletedQueryInfo) => Promise, private doCompareCallback: ( - from: CompletedQuery, - to: CompletedQuery + from: FullCompletedQueryInfo, + to: FullCompletedQueryInfo ) => Promise ) { super(); - const treeDataProvider = (this.treeDataProvider = new HistoryTreeDataProvider( + this.treeDataProvider = this.push(new HistoryTreeDataProvider( extensionPath )); - this.treeView = Window.createTreeView('codeQLQueryHistory', { - treeDataProvider, + this.treeView = this.push(window.createTreeView('codeQLQueryHistory', { + treeDataProvider: this.treeDataProvider, canSelectMany: true, - }); - this.push(this.treeView); - this.push(treeDataProvider); + })); // Lazily update the tree view selection due to limitations of TreeView API (see // `updateTreeViewSelectionIfVisible` doc for details) @@ -331,20 +359,22 @@ export class QueryHistoryManager extends DisposableObject { this.push( commandRunner( 'codeQLQueryHistory.itemClicked', - async (item: CompletedQuery) => { + async (item: FullQueryInfo) => { return this.handleItemClicked(item, [item]); } ) ); - queryHistoryConfigListener.onDidChangeConfiguration(() => { - this.treeDataProvider.refresh(); - }); + this.push( + queryHistoryConfigListener.onDidChangeConfiguration(() => { + this.treeDataProvider.refresh(); + }) + ); // displays query text in a read-only document - vscode.workspace.registerTextDocumentContentProvider('codeql', { + this.push(workspace.registerTextDocumentContentProvider('codeql', { provideTextDocumentContent( - uri: vscode.Uri - ): vscode.ProviderResult { + uri: Uri + ): ProviderResult { const params = new URLSearchParams(uri.query); return ( @@ -353,19 +383,19 @@ export class QueryHistoryManager extends DisposableObject { : SHOW_QUERY_TEXT_MSG) + params.get('queryText') ); }, - }); + })); } - async invokeCallbackOn(queryHistoryItem: CompletedQuery) { - if (this.selectedCallback !== undefined) { + async invokeCallbackOn(queryHistoryItem: FullQueryInfo) { + if (this.selectedCallback && queryHistoryItem.isCompleted()) { const sc = this.selectedCallback; - await sc(queryHistoryItem); + await sc(queryHistoryItem as FullCompletedQueryInfo); } } async handleOpenQuery( - singleItem: CompletedQuery, - multiSelect: CompletedQuery[] + singleItem: FullQueryInfo, + multiSelect: FullQueryInfo[] ): Promise { const { finalSingleItem, finalMultiSelect } = this.determineSelection(singleItem, multiSelect); if (!this.assertSingleQuery(finalMultiSelect)) { @@ -376,19 +406,23 @@ export class QueryHistoryManager extends DisposableObject { throw new Error(NO_QUERY_SELECTED); } - const textDocument = await vscode.workspace.openTextDocument( - vscode.Uri.file(finalSingleItem.query.program.queryPath) + if (!finalSingleItem.completedQuery) { + throw new Error('Select a completed query.'); + } + + const textDocument = await workspace.openTextDocument( + Uri.file(finalSingleItem.completedQuery.query.program.queryPath) ); - const editor = await vscode.window.showTextDocument( + const editor = await window.showTextDocument( textDocument, - vscode.ViewColumn.One + ViewColumn.One ); - const queryText = finalSingleItem.options.queryText; - if (queryText !== undefined && finalSingleItem.options.isQuickQuery) { + const queryText = finalSingleItem.initialInfo.queryText; + if (queryText !== undefined && finalSingleItem.initialInfo.isQuickQuery) { await editor.edit((edit) => edit.replace( textDocument.validateRange( - new vscode.Range(0, 0, textDocument.lineCount, 0) + new Range(0, 0, textDocument.lineCount, 0) ), queryText ) @@ -397,14 +431,14 @@ export class QueryHistoryManager extends DisposableObject { } async handleRemoveHistoryItem( - singleItem: CompletedQuery, - multiSelect: CompletedQuery[] + singleItem: FullQueryInfo, + multiSelect: FullQueryInfo[] ) { const { finalSingleItem, finalMultiSelect } = this.determineSelection(singleItem, multiSelect); (finalMultiSelect || [finalSingleItem]).forEach((item) => { this.treeDataProvider.remove(item); - item.dispose(); + item.completedQuery?.dispose(); }); const current = this.treeDataProvider.getCurrent(); if (current !== undefined) { @@ -438,22 +472,22 @@ export class QueryHistoryManager extends DisposableObject { } async handleSetLabel( - singleItem: CompletedQuery, - multiSelect: CompletedQuery[] + singleItem: FullQueryInfo, + multiSelect: FullQueryInfo[] ): Promise { if (!this.assertSingleQuery(multiSelect)) { return; } - const response = await vscode.window.showInputBox({ + const response = await window.showInputBox({ prompt: 'Label:', placeHolder: '(use default)', - value: singleItem.getLabel(), + value: singleItem.label, }); // undefined response means the user cancelled the dialog; don't change anything if (response !== undefined) { // Interpret empty string response as 'go back to using default' - singleItem.options.label = response === '' ? undefined : response; + singleItem.initialInfo.userSpecifiedLabel = response === '' ? undefined : response; if (this.treeDataProvider.sortOrder === SortOrder.NameAsc || this.treeDataProvider.sortOrder === SortOrder.NameDesc) { this.treeDataProvider.refresh(); @@ -464,19 +498,19 @@ export class QueryHistoryManager extends DisposableObject { } async handleCompareWith( - singleItem: CompletedQuery, - multiSelect: CompletedQuery[] + singleItem: FullQueryInfo, + multiSelect: FullQueryInfo[] ) { try { - if (!singleItem.didRunSuccessfully) { + if (!singleItem.completedQuery?.didRunSuccessfully) { throw new Error('Please select a successful query.'); } const from = this.compareWithItem || singleItem; const to = await this.findOtherQueryToCompare(from, multiSelect); - if (from && to) { - await this.doCompareCallback(from, to); + if (from.isCompleted() && to?.isCompleted()) { + await this.doCompareCallback(from as FullCompletedQueryInfo, to as FullCompletedQueryInfo); } } catch (e) { void showAndLogErrorMessage(e.message); @@ -484,8 +518,8 @@ export class QueryHistoryManager extends DisposableObject { } async handleItemClicked( - singleItem: CompletedQuery, - multiSelect: CompletedQuery[] + singleItem: FullQueryInfo, + multiSelect: FullQueryInfo[] ) { const { finalSingleItem, finalMultiSelect } = this.determineSelection(singleItem, multiSelect); if (!this.assertSingleQuery(finalMultiSelect)) { @@ -516,23 +550,27 @@ export class QueryHistoryManager extends DisposableObject { } async handleShowQueryLog( - singleItem: CompletedQuery, - multiSelect: CompletedQuery[] + singleItem: FullQueryInfo, + multiSelect: FullQueryInfo[] ) { if (!this.assertSingleQuery(multiSelect)) { return; } - if (singleItem.logFileLocation) { - await this.tryOpenExternalFile(singleItem.logFileLocation); + if (!singleItem.completedQuery) { + return; + } + + if (singleItem.completedQuery.logFileLocation) { + await this.tryOpenExternalFile(singleItem.completedQuery.logFileLocation); } else { void showAndLogWarningMessage('No log file available'); } } async handleShowQueryText( - singleItem: CompletedQuery, - multiSelect: CompletedQuery[] + singleItem: FullQueryInfo, + multiSelect: FullQueryInfo[] ) { if (!this.assertSingleQuery(multiSelect)) { return; @@ -542,35 +580,38 @@ export class QueryHistoryManager extends DisposableObject { throw new Error(NO_QUERY_SELECTED); } - const queryName = singleItem.queryName.endsWith('.ql') - ? singleItem.queryName - : singleItem.queryName + '.ql'; - const params = new URLSearchParams({ - isQuickEval: String(!!singleItem.query.quickEvalPosition), - queryText: encodeURIComponent(await this.getQueryText(singleItem)), - }); - const uri = vscode.Uri.parse( - `codeql:${singleItem.query.queryID}-${queryName}?${params.toString()}`, true - ); - const doc = await vscode.workspace.openTextDocument(uri); - await vscode.window.showTextDocument(doc, { preview: false }); - } - - async handleViewSarifAlerts( - singleItem: CompletedQuery, - multiSelect: CompletedQuery[] - ) { - if (!this.assertSingleQuery(multiSelect)) { + if (!singleItem.completedQuery) { return; } - const hasInterpretedResults = await singleItem.query.canHaveInterpretedResults(); + const rawQueryName = singleItem.getQueryName(); + const queryName = rawQueryName.endsWith('.ql') ? rawQueryName : rawQueryName + '.ql'; + const params = new URLSearchParams({ + isQuickEval: String(!!singleItem.completedQuery.query.quickEvalPosition), + queryText: encodeURIComponent(await this.getQueryText(singleItem)), + }); + const uri = Uri.parse( + `codeql:${singleItem.completedQuery.query.queryID}-${queryName}?${params.toString()}`, true + ); + const doc = await workspace.openTextDocument(uri); + await window.showTextDocument(doc, { preview: false }); + } + + async handleViewSarifAlerts( + singleItem: FullQueryInfo, + multiSelect: FullQueryInfo[] + ) { + if (!this.assertSingleQuery(multiSelect) || !singleItem.completedQuery) { + return; + } + const query = singleItem.completedQuery.query; + const hasInterpretedResults = await query.canHaveInterpretedResults(); if (hasInterpretedResults) { await this.tryOpenExternalFile( - singleItem.query.resultsPaths.interpretedResultsPath + query.resultsPaths.interpretedResultsPath ); } else { - const label = singleItem.getLabel(); + const label = singleItem.label; void showAndLogInformationMessage( `Query ${label} has no interpreted results.` ); @@ -578,81 +619,87 @@ export class QueryHistoryManager extends DisposableObject { } async handleViewCsvResults( - singleItem: CompletedQuery, - multiSelect: CompletedQuery[] + singleItem: FullQueryInfo, + multiSelect: FullQueryInfo[] ) { if (!this.assertSingleQuery(multiSelect)) { return; } - if (await singleItem.query.hasCsv()) { - void this.tryOpenExternalFile(singleItem.query.csvPath); + if (!singleItem.completedQuery) { return; } - await singleItem.query.exportCsvResults(this.qs, singleItem.query.csvPath, () => { + const query = singleItem.completedQuery.query; + if (await query.hasCsv()) { + void this.tryOpenExternalFile(query.csvPath); + return; + } + await query.exportCsvResults(this.qs, query.csvPath, () => { void this.tryOpenExternalFile( - singleItem.query.csvPath + query.csvPath ); }); } async handleViewCsvAlerts( - singleItem: CompletedQuery, - multiSelect: CompletedQuery[] + singleItem: FullQueryInfo, + multiSelect: FullQueryInfo[] ) { - if (!this.assertSingleQuery(multiSelect)) { + if (!this.assertSingleQuery(multiSelect) || !singleItem.completedQuery) { return; } await this.tryOpenExternalFile( - await singleItem.query.ensureCsvProduced(this.qs) + await singleItem.completedQuery.query.ensureCsvProduced(this.qs) ); } async handleViewDil( - singleItem: CompletedQuery, - multiSelect: CompletedQuery[], + singleItem: FullQueryInfo, + multiSelect: FullQueryInfo[], ) { if (!this.assertSingleQuery(multiSelect)) { return; } + if (!singleItem.completedQuery) { + return; + } await this.tryOpenExternalFile( - await singleItem.query.ensureDilPath(this.qs) + await singleItem.completedQuery.query.ensureDilPath(this.qs) ); } - async getQueryText(queryHistoryItem: CompletedQuery): Promise { - if (queryHistoryItem.options.queryText) { - return queryHistoryItem.options.queryText; - } else if (queryHistoryItem.query.quickEvalPosition) { + async getQueryText(queryHistoryItem: FullQueryInfo): Promise { + if (queryHistoryItem.initialInfo.queryText) { + return queryHistoryItem.initialInfo.queryText; + } + + if (!queryHistoryItem.completedQuery) { + return ''; + } + + const query = queryHistoryItem.completedQuery.query; + + if (query.quickEvalPosition) { // capture all selected lines - const startLine = queryHistoryItem.query.quickEvalPosition.line; - const endLine = queryHistoryItem.query.quickEvalPosition.endLine; - const textDocument = await vscode.workspace.openTextDocument( - queryHistoryItem.query.quickEvalPosition.fileName + const startLine = query.quickEvalPosition.line; + const endLine = query.quickEvalPosition.endLine; + const textDocument = await workspace.openTextDocument( + query.quickEvalPosition.fileName ); return textDocument.getText( - new vscode.Range(startLine - 1, 0, endLine, 0) + new Range(startLine - 1, 0, endLine, 0) ); } else { return ''; } } - buildCompletedQuery(info: QueryWithResults): CompletedQuery { - const item = new CompletedQuery(info, this.queryHistoryConfigListener); - return item; - } - - addCompletedQuery(item: CompletedQuery) { + addCompletedQuery(item: FullQueryInfo) { this.treeDataProvider.pushQuery(item); this.updateTreeViewSelectionIfVisible(); } - find(queryId: number): CompletedQuery | undefined { - return this.treeDataProvider.find(queryId); - } - /** * Update the tree view selection if the tree view is visible. * @@ -674,9 +721,9 @@ export class QueryHistoryManager extends DisposableObject { } private async tryOpenExternalFile(fileLocation: string) { - const uri = vscode.Uri.file(fileLocation); + const uri = Uri.file(fileLocation); try { - await vscode.window.showTextDocument(uri, { preview: false }); + await window.showTextDocument(uri, { preview: false }); } catch (e) { if ( e.message.includes( @@ -693,7 +740,7 @@ the file in the file explorer and dragging it into the workspace.` ); if (res) { try { - await vscode.commands.executeCommand('revealFileInOS', uri); + await commands.executeCommand('revealFileInOS', uri); } catch (e) { void showAndLogErrorMessage(e.message); } @@ -707,20 +754,26 @@ the file in the file explorer and dragging it into the workspace.` } private async findOtherQueryToCompare( - singleItem: CompletedQuery, - multiSelect: CompletedQuery[] - ): Promise { - const dbName = singleItem.database.name; + singleItem: FullQueryInfo, + multiSelect: FullQueryInfo[] + ): Promise { + if (!singleItem.completedQuery) { + return undefined; + } + const dbName = singleItem.initialInfo.databaseInfo.name; // if exactly 2 queries are selected, use those if (multiSelect?.length === 2) { // return the query that is not the first selected one const otherQuery = singleItem === multiSelect[0] ? multiSelect[1] : multiSelect[0]; - if (!otherQuery.didRunSuccessfully) { + if (!otherQuery.completedQuery) { + throw new Error('Please select a completed query.'); + } + if (!otherQuery.completedQuery.didRunSuccessfully) { throw new Error('Please select a successful query.'); } - if (otherQuery.database.name !== dbName) { + if (otherQuery.initialInfo.databaseInfo.name !== dbName) { throw new Error('Query databases must be the same.'); } return otherQuery; @@ -735,23 +788,24 @@ the file in the file explorer and dragging it into the workspace.` .filter( (otherQuery) => otherQuery !== singleItem && - otherQuery.didRunSuccessfully && - otherQuery.database.name === dbName + otherQuery.completedQuery && + otherQuery.completedQuery.didRunSuccessfully && + otherQuery.initialInfo.databaseInfo.name === dbName ) - .map((otherQuery) => ({ - label: otherQuery.toString(), - description: otherQuery.databaseName, - detail: otherQuery.statusString, - query: otherQuery, + .map((item) => ({ + label: item.label, + description: item.initialInfo.databaseInfo.name, + detail: item.completedQuery!.statusString, + query: item, })); if (comparableQueryLabels.length < 1) { throw new Error('No other queries available to compare with.'); } - const choice = await vscode.window.showQuickPick(comparableQueryLabels); + const choice = await window.showQuickPick(comparableQueryLabels); return choice?.query; } - private assertSingleQuery(multiSelect: CompletedQuery[] = [], message = 'Please select a single query.') { + private assertSingleQuery(multiSelect: FullQueryInfo[] = [], message = 'Please select a single query.') { if (multiSelect.length > 1) { void showAndLogErrorMessage( message @@ -778,7 +832,7 @@ the file in the file explorer and dragging it into the workspace.` * * @param newSelection the new selection after the most recent selection change */ - private updateCompareWith(newSelection: CompletedQuery[]) { + private updateCompareWith(newSelection: FullQueryInfo[]) { if (newSelection.length === 1) { this.compareWithItem = newSelection[0]; } else if ( @@ -799,9 +853,9 @@ the file in the file explorer and dragging it into the workspace.` * @param multiSelect a multi-select or undefined if no items are selected */ private determineSelection( - singleItem: CompletedQuery, - multiSelect: CompletedQuery[] - ): { finalSingleItem: CompletedQuery; finalMultiSelect: CompletedQuery[] } { + singleItem: FullQueryInfo, + multiSelect: FullQueryInfo[] + ): { finalSingleItem: FullQueryInfo; finalMultiSelect: FullQueryInfo[] } { if (singleItem === undefined && (multiSelect === undefined || multiSelect.length === 0 || multiSelect[0] === undefined)) { const selection = this.treeView.selection; if (selection) { @@ -817,7 +871,7 @@ the file in the file explorer and dragging it into the workspace.` }; } - async refreshTreeView(completedQuery: CompletedQuery): Promise { - this.treeDataProvider.refresh(completedQuery); + async refreshTreeView(item: FullQueryInfo): Promise { + this.treeDataProvider.refresh(item); } } diff --git a/extensions/ql-vscode/src/query-results.ts b/extensions/ql-vscode/src/query-results.ts index c2a5922f9..8752b1be7 100644 --- a/extensions/ql-vscode/src/query-results.ts +++ b/extensions/ql-vscode/src/query-results.ts @@ -1,23 +1,46 @@ import { env } from 'vscode'; -import { QueryWithResults, tmpDir, QueryInfo } from './run-queries'; +import { QueryWithResults, tmpDir, QueryEvaluatonInfo } from './run-queries'; import * as messages from './pure/messages'; import * as cli from './cli'; import * as sarif from 'sarif'; import * as fs from 'fs-extra'; import * as path from 'path'; -import { RawResultsSortState, SortedResultSetInfo, DatabaseInfo, QueryMetadata, InterpretedResultsSortState, ResultsPaths } from './pure/interface-types'; +import { + RawResultsSortState, + SortedResultSetInfo, + QueryMetadata, + InterpretedResultsSortState, + ResultsPaths +} from './pure/interface-types'; import { QueryHistoryConfig } from './config'; -import { QueryHistoryItemOptions } from './query-history'; +import { DatabaseInfo } from './pure/interface-types'; -export class CompletedQuery implements QueryWithResults { - readonly date: Date; - readonly time: string; - readonly query: QueryInfo; +/** + * A description of the information about a query + * that is available before results are populated. + */ +export interface InitialQueryInfo { + userSpecifiedLabel?: string; // if missing, use a default label + readonly queryText?: string; // text of the selected file + readonly isQuickQuery: boolean; + readonly isQuickEval: boolean; + readonly quickEvalPosition?: messages.Position; + readonly queryPath: string; + readonly databaseInfo: DatabaseInfo + readonly start: Date; +} + +export enum QueryStatus { + InProgress = 'InProgress', + Completed = 'Completed', + Failed = 'Failed', +} + +export class CompletedQueryInfo implements QueryWithResults { + readonly query: QueryEvaluatonInfo; readonly result: messages.EvaluationResult; - readonly database: DatabaseInfo; readonly logFileLocation?: string; - options: QueryHistoryItemOptions; resultCount: number; dispose: () => void; @@ -37,17 +60,12 @@ export class CompletedQuery implements QueryWithResults { constructor( evaluation: QueryWithResults, - public config: QueryHistoryConfig, ) { this.query = evaluation.query; this.result = evaluation.result; - this.database = evaluation.database; this.logFileLocation = evaluation.logFileLocation; - this.options = evaluation.options; this.dispose = evaluation.dispose; - this.date = new Date(); - this.time = this.date.toLocaleString(env.language); this.sortedResultsInfo = new Map(); this.resultCount = 0; } @@ -56,26 +74,16 @@ export class CompletedQuery implements QueryWithResults { this.resultCount = value; } - get databaseName(): string { - return this.database.name; - } - get queryName(): string { - return getQueryName(this.query); - } - get queryFileName(): string { - return getQueryFileName(this.query); - } - get statusString(): string { switch (this.result.resultType) { case messages.QueryResultType.CANCELLATION: - return `cancelled after ${this.result.evaluationTime / 1000} seconds`; + return `cancelled after ${Math.round(this.result.evaluationTime / 1000)} seconds`; case messages.QueryResultType.OOM: return 'out of memory'; case messages.QueryResultType.SUCCESS: - return `finished in ${this.result.evaluationTime / 1000} seconds`; + return `finished in ${Math.round(this.result.evaluationTime / 1000)} seconds`; case messages.QueryResultType.TIMEOUT: - return `timed out after ${this.result.evaluationTime / 1000} seconds`; + return `timed out after ${Math.round(this.result.evaluationTime / 1000)} seconds`; case messages.QueryResultType.OTHER_ERROR: default: return this.result.message ? `failed: ${this.result.message}` : 'failed'; @@ -90,36 +98,10 @@ export class CompletedQuery implements QueryWithResults { || this.query.resultsPaths.resultsPath; } - interpolate(template: string): string { - const { databaseName, queryName, time, resultCount, statusString, queryFileName } = this; - const replacements: { [k: string]: string } = { - t: time, - q: queryName, - d: databaseName, - r: resultCount.toString(), - s: statusString, - f: queryFileName, - '%': '%', - }; - return template.replace(/%(.)/g, (match, key) => { - const replacement = replacements[key]; - return replacement !== undefined ? replacement : match; - }); - } - - getLabel(): string { - return this.options?.label - || this.config.format; - } - get didRunSuccessfully(): boolean { return this.result.resultType === messages.QueryResultType.SUCCESS; } - toString(): string { - return this.interpolate(this.getLabel()); - } - async updateSortState( server: cli.CodeQLCliServer, resultSetName: string, @@ -151,36 +133,6 @@ export class CompletedQuery implements QueryWithResults { } -/** - * Gets a human-readable name for an evaluated query. - * Uses metadata if it exists, and defaults to the query file name. - */ -export function getQueryName(query: QueryInfo) { - if (query.quickEvalPosition !== undefined) { - return 'Quick evaluation of ' + getQueryFileName(query); - } else if (query.metadata?.name) { - return query.metadata.name; - } else { - return getQueryFileName(query); - } -} - -/** - * Gets the file name for an evaluated query. - * Defaults to the query file name and may contain position information for quick eval queries. - */ -export function getQueryFileName(query: QueryInfo) { - // Queries run through quick evaluation are not usually the entire query file. - // Label them differently and include the line numbers. - if (query.quickEvalPosition !== undefined) { - const { line, endLine, fileName } = query.quickEvalPosition; - const lineInfo = line === endLine ? `${line}` : `${line}-${endLine}`; - return `${path.basename(fileName)}:${lineInfo}`; - } - return path.basename(query.program.queryPath); -} - - /** * Call cli command to interpret results. */ @@ -211,3 +163,121 @@ export function ensureMetadataIsComplete(metadata: QueryMetadata | undefined) { } return metadata; } + + +/** + * Used in Interface and Compare-Interface for queries that we know have been complated. + */ +export type FullCompletedQueryInfo = FullQueryInfo & { + completedQuery: CompletedQueryInfo +}; + +export class FullQueryInfo { + public failureReason: string | undefined; + public completedQuery: CompletedQueryInfo | undefined; + + constructor( + public readonly initialInfo: InitialQueryInfo, + private readonly config: QueryHistoryConfig, + ) { + /**/ + } + + get time() { + return this.initialInfo.start.toLocaleString(env.language); + } + + interpolate(template: string): string { + const { resultCount = 0, statusString = 'in progress' } = this.completedQuery || {}; + const replacements: { [k: string]: string } = { + t: this.time, + q: this.getQueryName(), + d: this.initialInfo.databaseInfo.name, + r: resultCount.toString(), + s: statusString, + f: this.getQueryFileName(), + '%': '%', + }; + return template.replace(/%(.)/g, (match, key) => { + const replacement = replacements[key]; + return replacement !== undefined ? replacement : match; + }); + } + + /** + * Returns a label for this query that includes interpolated values. + */ + get label(): string { + return this.interpolate(this.initialInfo.userSpecifiedLabel ?? this.config.format ?? ''); + } + + /** + * Avoids getting the default label for the query. + * If there is a custom label for this query, interpolate and use that. + * Otherwise, use the name of the query. + * + * @returns the name of the query, unless there is a custom label for this query. + */ + getShortLabel(): string { + return this.initialInfo.userSpecifiedLabel + ? this.interpolate(this.initialInfo.userSpecifiedLabel) + : this.getQueryName(); + } + + /** + * The query's file name, unless it is a quick eval. + * Queries run through quick evaluation are not usually the entire query file. + * Label them differently and include the line numbers. + */ + getQueryFileName() { + if (this.initialInfo.quickEvalPosition) { + const { line, endLine, fileName } = this.initialInfo.quickEvalPosition; + const lineInfo = line === endLine ? `${line}` : `${line}-${endLine}`; + return `${path.basename(fileName)}:${lineInfo}`; + } + return path.basename(this.initialInfo.queryPath); + } + + /** + * Three cases: + * + * - If this is a completed query, use the query name from the query metadata. + * - If this is a quick eval, return the query name with a prefix + * - Otherwise, return the query file name. + */ + getQueryName() { + if (this.initialInfo.quickEvalPosition) { + return 'Quick evaluation of ' + this.getQueryFileName(); + } else if (this.completedQuery?.query.metadata?.name) { + return this.completedQuery?.query.metadata?.name; + } else { + return this.getQueryFileName(); + } + } + + isCompleted(): boolean { + return !!this.completedQuery; + } + + completeThisQuery(info: QueryWithResults) { + this.completedQuery = new CompletedQueryInfo(info); + } + + /** + * If there is a failure reason, then this query has failed. + * If there is no completed query, then this query is still running. + * If there is a completed query, then check if didRunSuccessfully. + * If true, then this query has completed successfully, otherwise it has failed. + */ + get status(): QueryStatus { + if (this.failureReason) { + return QueryStatus.Failed; + } else if (!this.completedQuery) { + return QueryStatus.InProgress; + } else if (this.completedQuery.didRunSuccessfully) { + return QueryStatus.Completed; + } else { + return QueryStatus.Failed; + } + } +} diff --git a/extensions/ql-vscode/src/remote-queries/remote-queries-interface.ts b/extensions/ql-vscode/src/remote-queries/remote-queries-interface.ts index 45a13910a..cbdd28980 100644 --- a/extensions/ql-vscode/src/remote-queries/remote-queries-interface.ts +++ b/extensions/ql-vscode/src/remote-queries/remote-queries-interface.ts @@ -58,7 +58,7 @@ export class RemoteQueriesInterfaceManager { /** * Builds up a model tailored to the view based on the query and result domain entities. * The data is cleaned up, sorted where necessary, and transformed to a format that - * the view model can use. + * the view model can use. * @param query Information about the query that was run. * @param queryResult The result of the query. * @returns A fully created view model. @@ -127,10 +127,12 @@ export class RemoteQueriesInterfaceManager { scriptPathOnDisk, [baseStylesheetUriOnDisk, stylesheetPathOnDisk] ); - panel.webview.onDidReceiveMessage( - async (e) => this.handleMsgFromView(e), - undefined, - ctx.subscriptions + ctx.subscriptions.push( + panel.webview.onDidReceiveMessage( + async (e) => this.handleMsgFromView(e), + undefined, + ctx.subscriptions + ) ); } return this.panel; diff --git a/extensions/ql-vscode/src/run-queries.ts b/extensions/ql-vscode/src/run-queries.ts index 1154241d8..7dcb60e84 100644 --- a/extensions/ql-vscode/src/run-queries.ts +++ b/extensions/ql-vscode/src/run-queries.ts @@ -22,7 +22,7 @@ import { ProgressCallback, UserCancellationException } from './commandRunner'; import { DatabaseInfo, QueryMetadata, ResultsPaths } from './pure/interface-types'; import { logger } from './logging'; import * as messages from './pure/messages'; -import { QueryHistoryItemOptions } from './query-history'; +import { InitialQueryInfo } from './query-results'; import * as qsClient from './queryserver-client'; import { isQuickQueryPath } from './quick-query'; import { compileDatabaseUpgradeSequence, hasNondestructiveUpgradeCapabilities, upgradeDatabaseExplicit } from './upgrades'; @@ -53,7 +53,7 @@ export const tmpDirDisposal = { * temporary files associated with it, such as the compiled query * output and results. */ -export class QueryInfo { +export class QueryEvaluatonInfo { private static nextQueryId = 0; readonly compiledQueryPath: string; @@ -71,7 +71,7 @@ export class QueryInfo { public readonly metadata?: QueryMetadata, public readonly templates?: messages.TemplateDefinitions, ) { - this.queryID = QueryInfo.nextQueryId++; + this.queryID = QueryEvaluatonInfo.nextQueryId++; this.compiledQueryPath = path.join(tmpDir.name, `compiledQuery${this.queryID}.qlo`); this.dilPath = path.join(tmpDir.name, `results${this.queryID}.dil`); this.csvPath = path.join(tmpDir.name, `results${this.queryID}.csv`); @@ -269,10 +269,8 @@ export class QueryInfo { export interface QueryWithResults { - readonly query: QueryInfo; + readonly query: QueryEvaluatonInfo; readonly result: messages.EvaluationResult; - readonly database: DatabaseInfo; - readonly options: QueryHistoryItemOptions; readonly logFileLocation?: string; readonly dispose: () => void; } @@ -356,7 +354,7 @@ async function getSelectedPosition(editor: TextEditor, range?: Range): Promise { @@ -398,7 +396,7 @@ async function checkDbschemeCompatibility( } } -function reportNoUpgradePath(query: QueryInfo) { +function reportNoUpgradePath(query: QueryEvaluatonInfo) { throw new Error(`Query ${query.program.queryPath} expects database scheme ${query.queryDbscheme}, but the current database has a different scheme, and no database upgrades are available. The current database scheme may be newer than the CodeQL query libraries in your workspace.\n\nPlease try using a newer version of the query libraries.`); } @@ -408,7 +406,7 @@ function reportNoUpgradePath(query: QueryInfo) { async function compileNonDestructiveUpgrade( qs: qsClient.QueryServerClient, upgradeTemp: tmp.DirectoryResult, - query: QueryInfo, + query: QueryEvaluatonInfo, progress: ProgressCallback, token: CancellationToken, ): Promise { @@ -557,32 +555,19 @@ export async function compileAndRunQueryAgainstDatabase( cliServer: cli.CodeQLCliServer, qs: qsClient.QueryServerClient, db: DatabaseItem, - quickEval: boolean, - selectedQueryUri: Uri | undefined, + initialInfo: InitialQueryInfo, progress: ProgressCallback, token: CancellationToken, templates?: messages.TemplateDefinitions, - range?: Range ): Promise { if (!db.contents || !db.contents.dbSchemeUri) { throw new Error(`Database ${db.databaseUri} does not have a CodeQL database scheme.`); } - // Determine which query to run, based on the selection and the active editor. - const { queryPath, quickEvalPosition, quickEvalText } = await determineSelectedQuery(selectedQueryUri, quickEval, range); - - const historyItemOptions: QueryHistoryItemOptions = {}; - historyItemOptions.isQuickQuery === isQuickQueryPath(queryPath); - if (quickEval) { - historyItemOptions.queryText = quickEvalText; - } else { - historyItemOptions.queryText = await fs.readFile(queryPath, 'utf8'); - } - // Get the workspace folder paths. const diskWorkspaceFolders = getOnDiskWorkspaceFolders(); // Figure out the library path for the query. - const packConfig = await cliServer.resolveLibraryPath(diskWorkspaceFolders, queryPath); + const packConfig = await cliServer.resolveLibraryPath(diskWorkspaceFolders, initialInfo.queryPath); if (!packConfig.dbscheme) { throw new Error('Could not find a database scheme for this query. Please check that you have a valid qlpack.yml file for this query, which refers to a database scheme either in the `dbscheme` field or through one of its dependencies.'); @@ -596,7 +581,7 @@ export async function compileAndRunQueryAgainstDatabase( const dbSchemaName = path.basename(db.contents.dbSchemeUri.fsPath); if (querySchemaName != dbSchemaName) { void logger.log(`Query schema was ${querySchemaName}, but database schema was ${dbSchemaName}.`); - throw new Error(`The query ${path.basename(queryPath)} cannot be run against the selected database (${db.name}): their target languages are different. Please select a different database and try again.`); + throw new Error(`The query ${path.basename(initialInfo.queryPath)} cannot be run against the selected database (${db.name}): their target languages are different. Please select a different database and try again.`); } const qlProgram: messages.QlProgram = { @@ -608,7 +593,7 @@ export async function compileAndRunQueryAgainstDatabase( // we use the database's DB scheme here instead of the DB scheme // from the current document's project. dbschemePath: db.contents.dbSchemeUri.fsPath, - queryPath: queryPath + queryPath: initialInfo.queryPath }; // Read the query metadata if possible, to use in the UI. @@ -632,7 +617,7 @@ export async function compileAndRunQueryAgainstDatabase( } } - const query = new QueryInfo(qlProgram, db, packConfig.dbscheme, quickEvalPosition, metadata, templates); + const query = new QueryEvaluatonInfo(qlProgram, db, packConfig.dbscheme, initialInfo.quickEvalPosition, metadata, templates); const upgradeDir = await tmp.dir({ dir: upgradesTmpDir.name, unsafeCleanup: true }); try { @@ -647,7 +632,7 @@ export async function compileAndRunQueryAgainstDatabase( errors = await query.compile(qs, progress, token); } catch (e) { if (e instanceof ResponseError && e.code == ErrorCodes.RequestCancelled) { - return createSyntheticResult(query, db, historyItemOptions, 'Query cancelled', messages.QueryResultType.CANCELLATION); + return createSyntheticResult(query, 'Query cancelled', messages.QueryResultType.CANCELLATION); } else { throw e; } @@ -663,11 +648,6 @@ export async function compileAndRunQueryAgainstDatabase( return { query, result, - database: { - name: db.name, - databaseUri: db.databaseUri.toString(true) - }, - options: historyItemOptions, logFileLocation: result.logFileLocation, dispose: () => { qs.logger.removeAdditionalLogLocation(result.logFileLocation); @@ -688,16 +668,16 @@ export async function compileAndRunQueryAgainstDatabase( formattedMessages.push(formatted); void qs.logger.log(formatted); } - if (quickEval && formattedMessages.length <= 2) { + if (initialInfo.isQuickEval && formattedMessages.length <= 2) { // If there are more than 2 error messages, they will not be displayed well in a popup // and will be trimmed by the function displaying the error popup. Accordingly, we only // try to show the errors if there are 2 or less, otherwise we direct the user to the log. void showAndLogErrorMessage('Quick evaluation compilation failed: ' + formattedMessages.join('\n')); } else { - void showAndLogErrorMessage((quickEval ? 'Quick evaluation' : 'Query') + compilationFailedErrorTail); + void showAndLogErrorMessage((initialInfo.isQuickEval ? 'Quick evaluation' : 'Query') + compilationFailedErrorTail); } - return createSyntheticResult(query, db, historyItemOptions, 'Query had compilation errors', messages.QueryResultType.OTHER_ERROR); + return createSyntheticResult(query, 'Query had compilation errors', messages.QueryResultType.OTHER_ERROR); } } finally { try { @@ -708,14 +688,36 @@ export async function compileAndRunQueryAgainstDatabase( } } +export async function createInitialQueryInfo( + selectedQueryUri: Uri | undefined, + databaseInfo: DatabaseInfo, + isQuickEval: boolean, range?: Range +): Promise { + // Determine which query to run, based on the selection and the active editor. + const { queryPath, quickEvalPosition, quickEvalText } = await determineSelectedQuery(selectedQueryUri, isQuickEval, range); + + return { + queryPath, + isQuickEval, + isQuickQuery: isQuickQueryPath(queryPath), + databaseInfo, + start: new Date(), + ... (isQuickEval ? { + queryText: quickEvalText, + quickEvalPosition: quickEvalPosition + } : { + queryText: await fs.readFile(queryPath, 'utf8') + }) + }; +} + + const compilationFailedErrorTail = ' compilation failed. Please make sure there are no errors in the query, the database is up to date,' + ' and the query and database use the same target language. For more details on the error, go to View > Output,' + ' and choose CodeQL Query Server from the dropdown.'; function createSyntheticResult( - query: QueryInfo, - db: DatabaseItem, - historyItemOptions: QueryHistoryItemOptions, + query: QueryEvaluatonInfo, message: string, resultType: number ): QueryWithResults { @@ -729,11 +731,6 @@ function createSyntheticResult( runId: -1, message }, - database: { - name: db.name, - databaseUri: db.databaseUri.toString(true) - }, - options: historyItemOptions, dispose: () => { /**/ }, }; } diff --git a/extensions/ql-vscode/src/vscode-tests/cli-integration/queries.test.ts b/extensions/ql-vscode/src/vscode-tests/cli-integration/queries.test.ts index 826a23fd3..20a7c7932 100644 --- a/extensions/ql-vscode/src/vscode-tests/cli-integration/queries.test.ts +++ b/extensions/ql-vscode/src/vscode-tests/cli-integration/queries.test.ts @@ -11,7 +11,7 @@ import { DatabaseItem, DatabaseManager } from '../../databases'; import { CodeQLExtensionInterface } from '../../extension'; import { dbLoc, storagePath } from './global.helper'; import { importArchiveDatabase } from '../../databaseFetcher'; -import { compileAndRunQueryAgainstDatabase } from '../../run-queries'; +import { compileAndRunQueryAgainstDatabase, createInitialQueryInfo } from '../../run-queries'; import { CodeQLCliServer } from '../../cli'; import { QueryServerClient } from '../../queryserver-client'; import { skipIfNoCodeQL } from '../ensureCli'; @@ -96,15 +96,12 @@ describe('Queries', function() { cli, qs, dbItem, - false, - Uri.file(queryPath), + await mockInitialQueryInfo(queryPath), progress, token ); // just check that the query was successful - expect(result.database.name).to.eq('db'); - expect(result.options.queryText).to.eq(fs.readFileSync(queryPath, 'utf8')); expect(result.result.resultType).to.eq(QueryResultType.SUCCESS); } catch (e) { console.error('Test Failed'); @@ -121,15 +118,13 @@ describe('Queries', function() { cli, qs, dbItem, - false, - Uri.file(queryPath), + await mockInitialQueryInfo(queryPath), progress, token ); // this message would indicate that the databases were not properly reregistered expect(result.result.message).not.to.eq('No result from server'); - expect(result.options.queryText).to.eq(fs.readFileSync(queryPath, 'utf8')); expect(result.result.resultType).to.eq(QueryResultType.SUCCESS); } catch (e) { console.error('Test Failed'); @@ -174,4 +169,15 @@ describe('Queries', function() { // ignore } } + + async function mockInitialQueryInfo(queryPath: string) { + return await createInitialQueryInfo( + Uri.file(queryPath), + { + name: dbItem.name, + databaseUri: dbItem.databaseUri.toString(), + }, + false + ); + } }); diff --git a/extensions/ql-vscode/src/vscode-tests/no-workspace/query-history.test.ts b/extensions/ql-vscode/src/vscode-tests/no-workspace/query-history.test.ts index e7f36f21b..9513967fe 100644 --- a/extensions/ql-vscode/src/vscode-tests/no-workspace/query-history.test.ts +++ b/extensions/ql-vscode/src/vscode-tests/no-workspace/query-history.test.ts @@ -6,8 +6,11 @@ import * as sinon from 'sinon'; import * as chaiAsPromised from 'chai-as-promised'; import { logger } from '../../logging'; import { QueryHistoryManager, HistoryTreeDataProvider } from '../../query-history'; -import { CompletedQuery } from '../../query-results'; -import { QueryInfo } from '../../run-queries'; +import { QueryEvaluatonInfo, QueryWithResults } from '../../run-queries'; +import { QueryHistoryConfigListener } from '../../config'; +import * as messages from '../../pure/messages'; +import { QueryServerClient } from '../../queryserver-client'; +import { FullQueryInfo, InitialQueryInfo } from '../../query-results'; chai.use(chaiAsPromised); const expect = chai.expect; @@ -15,10 +18,14 @@ const assert = chai.assert; describe('query-history', () => { + let configListener: QueryHistoryConfigListener; let showTextDocumentSpy: sinon.SinonStub; let showInformationMessageSpy: sinon.SinonStub; let executeCommandSpy: sinon.SinonStub; let showQuickPickSpy: sinon.SinonStub; + let queryHistoryManager: QueryHistoryManager | undefined; + let selectedCallback: sinon.SinonStub; + let doCompareCallback: sinon.SinonStub; let tryOpenExternalFile: Function; let sandbox: sinon.SinonSandbox; @@ -38,9 +45,16 @@ describe('query-history', () => { executeCommandSpy = sandbox.stub(vscode.commands, 'executeCommand'); sandbox.stub(logger, 'log'); tryOpenExternalFile = (QueryHistoryManager.prototype as any).tryOpenExternalFile; + configListener = new QueryHistoryConfigListener(); + selectedCallback = sandbox.stub(); + doCompareCallback = sandbox.stub(); }); - afterEach(() => { + afterEach(async () => { + if (queryHistoryManager) { + queryHistoryManager.dispose(); + queryHistoryManager = undefined; + } sandbox.restore(); }); @@ -85,24 +99,24 @@ describe('query-history', () => { }); }); + let allHistory: FullQueryInfo[]; + + beforeEach(() => { + allHistory = [ + createMockFullQueryInfo('a', createMockQueryWithResults(true)), + createMockFullQueryInfo('b', createMockQueryWithResults(true)), + createMockFullQueryInfo('a', createMockQueryWithResults(false)), + createMockFullQueryInfo('a', createMockQueryWithResults(true)), + ]; + }); + describe('findOtherQueryToCompare', () => { - let allHistory: { database: { name: string }; didRunSuccessfully: boolean }[]; - - beforeEach(() => { - allHistory = [ - { didRunSuccessfully: true, database: { name: 'a' } }, - { didRunSuccessfully: true, database: { name: 'b' } }, - { didRunSuccessfully: false, database: { name: 'a' } }, - { didRunSuccessfully: true, database: { name: 'a' } }, - ]; - }); - it('should find the second query to compare when one is selected', async () => { const thisQuery = allHistory[3]; - const queryHistory = createMockQueryHistory(allHistory); + queryHistoryManager = await createMockQueryHistory(allHistory); showQuickPickSpy.returns({ query: allHistory[0] }); - const otherQuery = await queryHistory.findOtherQueryToCompare(thisQuery, []); + const otherQuery = await (queryHistoryManager as any).findOtherQueryToCompare(thisQuery, []); expect(otherQuery).to.eq(allHistory[0]); // only called with first item, other items filtered out @@ -112,9 +126,9 @@ describe('query-history', () => { it('should handle cancelling out of the quick select', async () => { const thisQuery = allHistory[3]; - const queryHistory = createMockQueryHistory(allHistory); + queryHistoryManager = await createMockQueryHistory(allHistory); - const otherQuery = await queryHistory.findOtherQueryToCompare(thisQuery, []); + const otherQuery = await (queryHistoryManager as any).findOtherQueryToCompare(thisQuery, []); expect(otherQuery).to.be.undefined; // only called with first item, other items filtered out @@ -124,20 +138,20 @@ describe('query-history', () => { it('should compare against 2 queries', async () => { const thisQuery = allHistory[3]; - const queryHistory = createMockQueryHistory(allHistory); + queryHistoryManager = await createMockQueryHistory(allHistory); - const otherQuery = await queryHistory.findOtherQueryToCompare(thisQuery, [thisQuery, allHistory[0]]); + const otherQuery = await (queryHistoryManager as any).findOtherQueryToCompare(thisQuery, [thisQuery, allHistory[0]]); expect(otherQuery).to.eq(allHistory[0]); expect(showQuickPickSpy).not.to.have.been.called; }); it('should throw an error when a query is not successful', async () => { const thisQuery = allHistory[3]; - const queryHistory = createMockQueryHistory(allHistory); - allHistory[0].didRunSuccessfully = false; + queryHistoryManager = await createMockQueryHistory(allHistory); + allHistory[0] = createMockFullQueryInfo('a', createMockQueryWithResults(false)); try { - await queryHistory.findOtherQueryToCompare(thisQuery, [thisQuery, allHistory[0]]); + await (queryHistoryManager as any).findOtherQueryToCompare(thisQuery, [thisQuery, allHistory[0]]); assert(false, 'Should have thrown'); } catch (e) { expect(e.message).to.eq('Please select a successful query.'); @@ -145,12 +159,12 @@ describe('query-history', () => { }); it('should throw an error when a databases are not the same', async () => { - const thisQuery = allHistory[3]; - const queryHistory = createMockQueryHistory(allHistory); - allHistory[0].database.name = 'c'; + queryHistoryManager = await createMockQueryHistory(allHistory); try { - await queryHistory.findOtherQueryToCompare(thisQuery, [thisQuery, allHistory[0]]); + // allHistory[0] is database a + // allHistory[1] is database b + await (queryHistoryManager as any).findOtherQueryToCompare(allHistory[0], [allHistory[0], allHistory[1]]); assert(false, 'Should have thrown'); } catch (e) { expect(e.message).to.eq('Query databases must be the same.'); @@ -159,10 +173,10 @@ describe('query-history', () => { it('should throw an error when more than 2 queries selected', async () => { const thisQuery = allHistory[3]; - const queryHistory = createMockQueryHistory(allHistory); + queryHistoryManager = await createMockQueryHistory(allHistory); try { - await queryHistory.findOtherQueryToCompare(thisQuery, [thisQuery, allHistory[0], allHistory[1]]); + await (queryHistoryManager as any).findOtherQueryToCompare(thisQuery, [thisQuery, allHistory[0], allHistory[1]]); assert(false, 'Should have thrown'); } catch (e) { expect(e.message).to.eq('Please select no more than 2 queries.'); @@ -170,39 +184,116 @@ describe('query-history', () => { }); }); + describe('handleItemClicked', () => { + it('should call the selectedCallback when an item is clicked', async () => { + queryHistoryManager = await createMockQueryHistory(allHistory); + await queryHistoryManager.handleItemClicked(allHistory[0], [allHistory[0]]); + expect(selectedCallback).to.have.been.calledOnceWith(allHistory[0]); + expect(queryHistoryManager.treeDataProvider.getCurrent()).to.eq(allHistory[0]); + }); + + it('should do nothing if there is a multi-selection', async () => { + queryHistoryManager = await createMockQueryHistory(allHistory); + await queryHistoryManager.handleItemClicked(allHistory[0], [allHistory[0], allHistory[1]]); + expect(selectedCallback).not.to.have.been.called; + expect(queryHistoryManager.treeDataProvider.getCurrent()).to.be.undefined; + }); + + it('should throw if there is no selection', async () => { + queryHistoryManager = await createMockQueryHistory(allHistory); + try { + await queryHistoryManager.handleItemClicked(undefined!, []); + expect(true).to.be.false; + } catch (e) { + expect(selectedCallback).not.to.have.been.called; + expect(e.message).to.contain('No query selected'); + } + }); + }); + + it('should remove an item and not select a new one', async function() { + queryHistoryManager = await createMockQueryHistory(allHistory); + // deleting the first item when a different item is selected + // will not change the selection + const toDelete = allHistory[1]; + const selected = allHistory[3]; + // avoid triggering the callback by setting the field directly + (queryHistoryManager.treeDataProvider as any).current = selected; + await queryHistoryManager.handleRemoveHistoryItem(toDelete, [toDelete]); + + expect(toDelete.completedQuery!.dispose).to.have.been.calledOnce; + expect(queryHistoryManager.treeDataProvider.getCurrent()).to.eq(selected); + expect(allHistory).not.to.contain(toDelete); + + // the current item should have been re-selected + expect(selectedCallback).to.have.been.calledOnceWith(selected); + }); + + it('should remove an item and select a new one', async () => { + queryHistoryManager = await createMockQueryHistory(allHistory); + + // deleting the selected item automatically selects next item + const toDelete = allHistory[1]; + const newSelected = allHistory[2]; + // avoid triggering the callback by setting the field directly + (queryHistoryManager.treeDataProvider as any).current = toDelete; + await queryHistoryManager.handleRemoveHistoryItem(toDelete, [toDelete]); + + expect(toDelete.completedQuery!.dispose).to.have.been.calledOnce; + expect(queryHistoryManager.treeDataProvider.getCurrent()).to.eq(newSelected); + expect(allHistory).not.to.contain(toDelete); + + // the current item should have been selected + expect(selectedCallback).to.have.been.calledOnceWith(newSelected); + }); + + describe('Compare callback', () => { + it('should call the compare callback', async () => { + queryHistoryManager = await createMockQueryHistory(allHistory); + await queryHistoryManager.handleCompareWith(allHistory[0], [allHistory[0], allHistory[3]]); + expect(doCompareCallback).to.have.been.calledOnceWith(allHistory[0], allHistory[3]); + }); + + it('should avoid calling the compare callback when only one item is selected', async () => { + queryHistoryManager = await createMockQueryHistory(allHistory); + await queryHistoryManager.handleCompareWith(allHistory[0], [allHistory[0]]); + expect(doCompareCallback).not.to.have.been.called; + }); + }); + describe('updateCompareWith', () => { - it('should update compareWithItem when there is a single item', () => { - const queryHistory = createMockQueryHistory([]); - queryHistory.updateCompareWith(['a']); - expect(queryHistory.compareWithItem).to.be.eq('a'); + it('should update compareWithItem when there is a single item', async () => { + queryHistoryManager = await createMockQueryHistory([]); + (queryHistoryManager as any).updateCompareWith(['a']); + expect(queryHistoryManager.compareWithItem).to.be.eq('a'); }); - it('should delete compareWithItem when there are 0 items', () => { - const queryHistory = createMockQueryHistory([]); - queryHistory.compareWithItem = 'a'; - queryHistory.updateCompareWith([]); - expect(queryHistory.compareWithItem).to.be.undefined; + it('should delete compareWithItem when there are 0 items', async () => { + queryHistoryManager = await createMockQueryHistory([]); + queryHistoryManager.compareWithItem = allHistory[0]; + (queryHistoryManager as any).updateCompareWith([]); + expect(queryHistoryManager.compareWithItem).to.be.undefined; }); - it('should delete compareWithItem when there are more than 2 items', () => { - const queryHistory = createMockQueryHistory([]); - queryHistory.compareWithItem = 'a'; - queryHistory.updateCompareWith(['a', 'b', 'c']); - expect(queryHistory.compareWithItem).to.be.undefined; + it('should delete compareWithItem when there are more than 2 items', async () => { + queryHistoryManager = await createMockQueryHistory(allHistory); + queryHistoryManager.compareWithItem = allHistory[0]; + (queryHistoryManager as any).updateCompareWith([allHistory[0], allHistory[1], allHistory[2]]); + expect(queryHistoryManager.compareWithItem).to.be.undefined; }); - it('should delete compareWithItem when there are 2 items and disjoint from compareWithItem', () => { - const queryHistory = createMockQueryHistory([]); - queryHistory.compareWithItem = 'a'; - queryHistory.updateCompareWith(['b', 'c']); - expect(queryHistory.compareWithItem).to.be.undefined; + it('should delete compareWithItem when there are 2 items and disjoint from compareWithItem', async () => { + queryHistoryManager = await createMockQueryHistory([]); + queryHistoryManager.compareWithItem = allHistory[0]; + (queryHistoryManager as any).updateCompareWith([allHistory[1], allHistory[2]]); + expect(queryHistoryManager.compareWithItem).to.be.undefined; }); - it('should do nothing when compareWithItem exists and exactly 2 items', () => { - const queryHistory = createMockQueryHistory([]); - queryHistory.compareWithItem = 'a'; - queryHistory.updateCompareWith(['a', 'b']); - expect(queryHistory.compareWithItem).to.be.eq('a'); + it('should do nothing when compareWithItem exists and exactly 2 items', async () => { + queryHistoryManager = await createMockQueryHistory([]); + queryHistoryManager.compareWithItem = allHistory[0]; + (queryHistoryManager as any).updateCompareWith([allHistory[0], allHistory[1]]); + expect(queryHistoryManager.compareWithItem).to.be.eq(allHistory[0]); }); }); @@ -212,70 +303,107 @@ describe('query-history', () => { historyTreeDataProvider = new HistoryTreeDataProvider(vscode.Uri.file('/a/b/c').fsPath); }); + afterEach(() => { + historyTreeDataProvider.dispose(); + }); + + it('should get a tree item with raw results', async () => { - const mockQuery = { - query: { - hasInterpretedResults: () => Promise.resolve(false) - } as QueryInfo, - didRunSuccessfully: true, - toString: () => 'mock label' - } as CompletedQuery; + const mockQuery = createMockFullQueryInfo('a', createMockQueryWithResults(true, /* raw results */ false)); const treeItem = await historyTreeDataProvider.getTreeItem(mockQuery); expect(treeItem.command).to.deep.eq({ title: 'Query History Item', command: 'codeQLQueryHistory.itemClicked', arguments: [mockQuery], }); - expect(treeItem.label).to.eq('mock label'); + expect(treeItem.label).to.contain('hucairz'); expect(treeItem.contextValue).to.eq('rawResultsItem'); - expect(treeItem.iconPath).to.be.undefined; + expect(treeItem.iconPath).to.deep.eq({ + id: 'check', color: undefined + }); }); it('should get a tree item with interpreted results', async () => { - const mockQuery = { - query: { - // as above, except for this line - hasInterpretedResults: () => Promise.resolve(true) - } as QueryInfo, - didRunSuccessfully: true, - toString: () => 'mock label' - } as CompletedQuery; + const mockQuery = createMockFullQueryInfo('a', createMockQueryWithResults(true, /* interpreted results */ true)); const treeItem = await historyTreeDataProvider.getTreeItem(mockQuery); expect(treeItem.contextValue).to.eq('interpretedResultsItem'); + expect(treeItem.iconPath).to.deep.eq({ + id: 'check', color: undefined + }); }); it('should get a tree item that did not complete successfully', async () => { - const mockQuery = { - query: { - hasInterpretedResults: () => Promise.resolve(true) - } as QueryInfo, - // as above, except for this line - didRunSuccessfully: false, - toString: () => 'mock label' - } as CompletedQuery; + const mockQuery = createMockFullQueryInfo('a', createMockQueryWithResults(false), false); const treeItem = await historyTreeDataProvider.getTreeItem(mockQuery); expect(treeItem.iconPath).to.eq(vscode.Uri.file('/a/b/c/media/red-x.svg').fsPath); }); + it('should get a tree item that failed before creating any results', async () => { + const mockQuery = createMockFullQueryInfo('a', undefined, true); + const treeItem = await historyTreeDataProvider.getTreeItem(mockQuery); + expect(treeItem.iconPath).to.eq(vscode.Uri.file('/a/b/c/media/red-x.svg').fsPath); + }); + + it('should get a tree item that is in progress', async () => { + const mockQuery = createMockFullQueryInfo('a'); + const treeItem = await historyTreeDataProvider.getTreeItem(mockQuery); + expect(treeItem.iconPath).to.deep.eq({ + id: 'search-refresh', color: undefined + }); + }); + it('should get children', () => { - const mockQuery = { - databaseName: 'abc' - } as CompletedQuery; + const mockQuery = createMockFullQueryInfo(); historyTreeDataProvider.allHistory.push(mockQuery); expect(historyTreeDataProvider.getChildren()).to.deep.eq([mockQuery]); expect(historyTreeDataProvider.getChildren(mockQuery)).to.deep.eq([]); }); }); -}); -function createMockQueryHistory(allHistory: Record[]) { - return { - assertSingleQuery: (QueryHistoryManager.prototype as any).assertSingleQuery, - findOtherQueryToCompare: (QueryHistoryManager.prototype as any).findOtherQueryToCompare, - treeDataProvider: { - allHistory - }, - updateCompareWith: (QueryHistoryManager.prototype as any).updateCompareWith, - compareWithItem: undefined as undefined | string, - }; -} + function createMockFullQueryInfo(dbName = 'a', queryWitbResults?: QueryWithResults, isFail = false): FullQueryInfo { + const fqi = new FullQueryInfo( + { + databaseInfo: { name: dbName }, + start: new Date(), + queryPath: 'hucairz' + } as InitialQueryInfo, + configListener + ); + + if (queryWitbResults) { + fqi.completeThisQuery(queryWitbResults); + } + if (isFail) { + fqi.failureReason = 'failure reason'; + } + return fqi; + } + + function createMockQueryWithResults(didRunSuccessfully = true, hasInterpretedResults = true): QueryWithResults { + return { + query: { + hasInterpretedResults: () => Promise.resolve(hasInterpretedResults) + } as QueryEvaluatonInfo, + result: { + resultType: didRunSuccessfully + ? messages.QueryResultType.SUCCESS + : messages.QueryResultType.OTHER_ERROR + } as messages.EvaluationResult, + dispose: sandbox.spy(), + }; + } + + async function createMockQueryHistory(allHistory: FullQueryInfo[]) { + const qhm = new QueryHistoryManager( + {} as QueryServerClient, + 'xxx', + configListener, + selectedCallback, + doCompareCallback + ); + (qhm.treeDataProvider as any).history = allHistory; + await vscode.workspace.saveAll(); + + return qhm; + } +}); diff --git a/extensions/ql-vscode/src/vscode-tests/no-workspace/query-results.test.ts b/extensions/ql-vscode/src/vscode-tests/no-workspace/query-results.test.ts index dedf26d80..444014aa2 100644 --- a/extensions/ql-vscode/src/vscode-tests/no-workspace/query-results.test.ts +++ b/extensions/ql-vscode/src/vscode-tests/no-workspace/query-results.test.ts @@ -3,162 +3,186 @@ import * as path from 'path'; import * as fs from 'fs-extra'; import 'mocha'; import 'sinon-chai'; -import * as Sinon from 'sinon'; +import * as sinon from 'sinon'; import * as chaiAsPromised from 'chai-as-promised'; -import { CompletedQuery, interpretResults } from '../../query-results'; -import { QueryInfo, QueryWithResults, tmpDir } from '../../run-queries'; +import { FullQueryInfo, InitialQueryInfo, interpretResults } from '../../query-results'; +import { QueryEvaluatonInfo, QueryWithResults, tmpDir } from '../../run-queries'; import { QueryHistoryConfig } from '../../config'; import { EvaluationResult, QueryResultType } from '../../pure/messages'; import { SortDirection, SortedResultSetInfo } from '../../pure/interface-types'; import { CodeQLCliServer, SourceInfo } from '../../cli'; +import { env } from 'process'; chai.use(chaiAsPromised); const expect = chai.expect; -describe('CompletedQuery', () => { - let disposeSpy: Sinon.SinonSpy; - let onDidChangeQueryHistoryConfigurationSpy: Sinon.SinonSpy; +describe('query-results', () => { + let disposeSpy: sinon.SinonSpy; + let onDidChangeQueryHistoryConfigurationSpy: sinon.SinonSpy; + let sandbox: sinon.SinonSandbox; beforeEach(() => { - disposeSpy = Sinon.spy(); - onDidChangeQueryHistoryConfigurationSpy = Sinon.spy(); + sandbox = sinon.createSandbox(); + disposeSpy = sandbox.spy(); + onDidChangeQueryHistoryConfigurationSpy = sandbox.spy(); }); - it('should construct a CompletedQuery', () => { - const completedQuery = mockCompletedQuery(); - - expect(completedQuery.logFileLocation).to.eq('mno'); - expect(completedQuery.databaseName).to.eq('def'); + afterEach(() => { + sandbox.restore(); }); - it('should get the query name', () => { - const completedQuery = mockCompletedQuery(); - - // from the query path - expect(completedQuery.queryName).to.eq('stu'); - - // from the metadata - (completedQuery.query as any).metadata = { - name: 'vwx' - }; - expect(completedQuery.queryName).to.eq('vwx'); - - // from quick eval position - (completedQuery.query as any).quickEvalPosition = { - line: 1, - endLine: 2, - fileName: '/home/users/yz' - }; - expect(completedQuery.queryName).to.eq('Quick evaluation of yz:1-2'); - (completedQuery.query as any).quickEvalPosition.endLine = 1; - expect(completedQuery.queryName).to.eq('Quick evaluation of yz:1'); - }); - - it('should get the query file name', () => { - const completedQuery = mockCompletedQuery(); - - // from the query path - expect(completedQuery.queryFileName).to.eq('stu'); - - // from quick eval position - (completedQuery.query as any).quickEvalPosition = { - line: 1, - endLine: 2, - fileName: '/home/users/yz' - }; - expect(completedQuery.queryFileName).to.eq('yz:1-2'); - (completedQuery.query as any).quickEvalPosition.endLine = 1; - expect(completedQuery.queryFileName).to.eq('yz:1'); - }); - - it('should get the label', () => { - const completedQuery = mockCompletedQuery(); - expect(completedQuery.getLabel()).to.eq('ghi'); - completedQuery.options.label = ''; - expect(completedQuery.getLabel()).to.eq('pqr'); - }); - - it('should get the getResultsPath', () => { - const completedQuery = mockCompletedQuery(); - // from results path - expect(completedQuery.getResultsPath('zxa', false)).to.eq('axa'); - - completedQuery.sortedResultsInfo.set('zxa', { - resultsPath: 'bxa' - } as SortedResultSetInfo); - - // still from results path - expect(completedQuery.getResultsPath('zxa', false)).to.eq('axa'); - - // from sortedResultsInfo - expect(completedQuery.getResultsPath('zxa')).to.eq('bxa'); - }); - - it('should get the statusString', () => { - const completedQuery = mockCompletedQuery(); - expect(completedQuery.statusString).to.eq('failed'); - - completedQuery.result.message = 'Tremendously'; - expect(completedQuery.statusString).to.eq('failed: Tremendously'); - - completedQuery.result.resultType = QueryResultType.OTHER_ERROR; - expect(completedQuery.statusString).to.eq('failed: Tremendously'); - - completedQuery.result.resultType = QueryResultType.CANCELLATION; - completedQuery.result.evaluationTime = 2000; - expect(completedQuery.statusString).to.eq('cancelled after 2 seconds'); - - completedQuery.result.resultType = QueryResultType.OOM; - expect(completedQuery.statusString).to.eq('out of memory'); - - completedQuery.result.resultType = QueryResultType.SUCCESS; - expect(completedQuery.statusString).to.eq('finished in 2 seconds'); - - completedQuery.result.resultType = QueryResultType.TIMEOUT; - expect(completedQuery.statusString).to.eq('timed out after 2 seconds'); - - }); - - it('should updateSortState', async () => { - const completedQuery = mockCompletedQuery(); - const spy = Sinon.spy(); - const mockServer = { - sortBqrs: spy - } as unknown as CodeQLCliServer; - const sortState = { - columnIndex: 1, - sortDirection: SortDirection.desc - }; - await completedQuery.updateSortState(mockServer, 'result-name', sortState); - const expectedPath = path.join(tmpDir.name, 'sortedResults111-result-name.bqrs'); - expect(spy).to.have.been.calledWith( - 'axa', - expectedPath, - 'result-name', - [sortState.columnIndex], - [sortState.sortDirection], - ); - - expect(completedQuery.sortedResultsInfo.get('result-name')).to.deep.equal({ - resultsPath: expectedPath, - sortState + describe('FullQueryInfo', () => { + it('should interpolate', () => { + const fqi = createMockFullQueryInfo(); + const date = new Date('2022-01-01T00:00:00.000Z'); + const dateStr = date.toLocaleString(env.language); + (fqi.initialInfo as any).start = date; + expect(fqi.interpolate('xxx')).to.eq('xxx'); + expect(fqi.interpolate('%t %q %d %s %%')).to.eq(`${dateStr} hucairz a in progress %`); + expect(fqi.interpolate('%t %q %d %s %%::%t %q %d %s %%')).to.eq(`${dateStr} hucairz a in progress %::${dateStr} hucairz a in progress %`); }); - // delete the sort stae - await completedQuery.updateSortState(mockServer, 'result-name'); - expect(completedQuery.sortedResultsInfo.size).to.eq(0); - }); + it('should get the query name', () => { + const fqi = createMockFullQueryInfo(); - it('should interpolate', () => { - const completedQuery = mockCompletedQuery(); - (completedQuery as any).time = '123'; - expect(completedQuery.interpolate('xxx')).to.eq('xxx'); - expect(completedQuery.interpolate('%t %q %d %s %%')).to.eq('123 stu def failed %'); - expect(completedQuery.interpolate('%t %q %d %s %%::%t %q %d %s %%')).to.eq('123 stu def failed %::123 stu def failed %'); + // from the query path + expect(fqi.getQueryName()).to.eq('hucairz'); + + fqi.completeThisQuery(createMockQueryWithResults()); + + // from the metadata + expect(fqi.getQueryName()).to.eq('vwx'); + + // from quick eval position + (fqi.initialInfo as any).quickEvalPosition = { + line: 1, + endLine: 2, + fileName: '/home/users/yz' + }; + expect(fqi.getQueryName()).to.eq('Quick evaluation of yz:1-2'); + (fqi.initialInfo as any).quickEvalPosition.endLine = 1; + expect(fqi.getQueryName()).to.eq('Quick evaluation of yz:1'); + }); + + it('should get the query file name', () => { + const fqi = createMockFullQueryInfo(); + + // from the query path + expect(fqi.getQueryFileName()).to.eq('hucairz'); + + // from quick eval position + (fqi.initialInfo as any).quickEvalPosition = { + line: 1, + endLine: 2, + fileName: '/home/users/yz' + }; + expect(fqi.getQueryFileName()).to.eq('yz:1-2'); + (fqi.initialInfo as any).quickEvalPosition.endLine = 1; + expect(fqi.getQueryFileName()).to.eq('yz:1'); + }); + + it('should get the label', () => { + const fqi = createMockFullQueryInfo('db-name'); + + // the %q from the config is now replaced by the file name of the query + expect(fqi.label).to.eq('from config hucairz'); + + // the %q from the config is now replaced by the name of the query + // in the metadata + fqi.completeThisQuery(createMockQueryWithResults()); + expect(fqi.label).to.eq('from config vwx'); + + // replace the config with a user specified label + // must be interpolated + fqi.initialInfo.userSpecifiedLabel = 'user specified label %d'; + expect(fqi.label).to.eq('user specified label db-name'); + }); + + it('should get the getResultsPath', () => { + const fqi = createMockFullQueryInfo('a', createMockQueryWithResults()); + const completedQuery = fqi.completedQuery!; + // from results path + expect(completedQuery.getResultsPath('zxa', false)).to.eq('/a/b/c'); + + completedQuery.sortedResultsInfo.set('zxa', { + resultsPath: 'bxa' + } as SortedResultSetInfo); + + // still from results path + expect(completedQuery.getResultsPath('zxa', false)).to.eq('/a/b/c'); + + // from sortedResultsInfo + expect(completedQuery.getResultsPath('zxa')).to.eq('bxa'); + }); + + it('should get the statusString', () => { + const fqi = createMockFullQueryInfo('a', createMockQueryWithResults(false)); + const completedQuery = fqi.completedQuery!; + + completedQuery.result.message = 'Tremendously'; + expect(completedQuery.statusString).to.eq('failed: Tremendously'); + + completedQuery.result.resultType = QueryResultType.OTHER_ERROR; + expect(completedQuery.statusString).to.eq('failed: Tremendously'); + + completedQuery.result.resultType = QueryResultType.CANCELLATION; + completedQuery.result.evaluationTime = 2345; + expect(completedQuery.statusString).to.eq('cancelled after 2 seconds'); + + completedQuery.result.resultType = QueryResultType.OOM; + expect(completedQuery.statusString).to.eq('out of memory'); + + completedQuery.result.resultType = QueryResultType.SUCCESS; + expect(completedQuery.statusString).to.eq('finished in 2 seconds'); + + completedQuery.result.resultType = QueryResultType.TIMEOUT; + expect(completedQuery.statusString).to.eq('timed out after 2 seconds'); + }); + + it('should updateSortState', async () => { + const fqi = createMockFullQueryInfo('a', createMockQueryWithResults()); + const completedQuery = fqi.completedQuery!; + + const spy = sandbox.spy(); + const mockServer = { + sortBqrs: spy + } as unknown as CodeQLCliServer; + const sortState = { + columnIndex: 1, + sortDirection: SortDirection.desc + }; + await completedQuery.updateSortState(mockServer, 'result-name', sortState); + const expectedPath = path.join(tmpDir.name, 'sortedResults6789-result-name.bqrs'); + expect(spy).to.have.been.calledWith( + '/a/b/c', + expectedPath, + 'result-name', + [sortState.columnIndex], + [sortState.sortDirection], + ); + + expect(completedQuery.sortedResultsInfo.get('result-name')).to.deep.equal({ + resultsPath: expectedPath, + sortState + }); + + // delete the sort stae + await completedQuery.updateSortState(mockServer, 'result-name'); + expect(completedQuery.sortedResultsInfo.size).to.eq(0); + }); + + // interpolate + // time + // label + // getShortLabel + // getQueryFileName + // getQueryName + + // status }); it('should interpretResults', async () => { - const spy = Sinon.mock(); + const spy = sandbox.mock(); spy.returns('1234'); const mockServer = { interpretBqrs: spy @@ -221,43 +245,52 @@ describe('CompletedQuery', () => { expect(results3).to.deep.eq({ a: 6 }); }); - function mockCompletedQuery() { - return new CompletedQuery( - mockQueryWithResults(), - mockQueryHistoryConfig() - ); - } - - function mockQueryWithResults(): QueryWithResults { + function createMockQueryWithResults(didRunSuccessfully = true, hasInterpretedResults = true): QueryWithResults { return { query: { - program: { - queryPath: 'stu' + hasInterpretedResults: () => Promise.resolve(hasInterpretedResults), + queryID: 6789, + metadata: { + name: 'vwx' }, resultsPaths: { - resultsPath: 'axa' - }, - queryID: 111 - } as never as QueryInfo, - result: {} as never as EvaluationResult, - database: { - databaseUri: 'abc', - name: 'def' - }, - options: { - label: 'ghi', - queryText: 'jkl', - isQuickQuery: false - }, - logFileLocation: 'mno', - dispose: disposeSpy + resultsPath: '/a/b/c', + interpretedResultsPath: '/d/e/f' + } + } as QueryEvaluatonInfo, + result: { + evaluationTime: 12340, + resultType: didRunSuccessfully + ? QueryResultType.SUCCESS + : QueryResultType.OTHER_ERROR + } as EvaluationResult, + dispose: disposeSpy, }; } + function createMockFullQueryInfo(dbName = 'a', queryWitbResults?: QueryWithResults, isFail = false): FullQueryInfo { + const fqi = new FullQueryInfo( + { + databaseInfo: { name: dbName }, + start: new Date(), + queryPath: 'path/to/hucairz' + } as InitialQueryInfo, + mockQueryHistoryConfig() + ); + + if (queryWitbResults) { + fqi.completeThisQuery(queryWitbResults); + } + if (isFail) { + fqi.failureReason = 'failure reason'; + } + return fqi; + } + function mockQueryHistoryConfig(): QueryHistoryConfig { return { onDidChangeConfiguration: onDidChangeQueryHistoryConfigurationSpy, - format: 'pqr' + format: 'from config %q' }; } }); diff --git a/extensions/ql-vscode/src/vscode-tests/no-workspace/run-queries.test.ts b/extensions/ql-vscode/src/vscode-tests/no-workspace/run-queries.test.ts index 14847bf77..064b02932 100644 --- a/extensions/ql-vscode/src/vscode-tests/no-workspace/run-queries.test.ts +++ b/extensions/ql-vscode/src/vscode-tests/no-workspace/run-queries.test.ts @@ -5,7 +5,7 @@ import 'sinon-chai'; import * as sinon from 'sinon'; import * as chaiAsPromised from 'chai-as-promised'; -import { QueryInfo } from '../../run-queries'; +import { QueryEvaluatonInfo } from '../../run-queries'; import { QlProgram, Severity, compileQuery } from '../../pure/messages'; import { DatabaseItem } from '../../databases'; @@ -13,7 +13,7 @@ chai.use(chaiAsPromised); const expect = chai.expect; describe('run-queries', () => { - it('should create a QueryInfo', () => { + it('should create a QueryEvaluatonInfo', () => { const info = createMockQueryInfo(); const queryID = info.queryID; @@ -85,7 +85,7 @@ describe('run-queries', () => { }); function createMockQueryInfo() { - return new QueryInfo( + return new QueryEvaluatonInfo( 'my-program' as unknown as QlProgram, { contents: {