diff --git a/extensions/ql-vscode/src/cli.ts b/extensions/ql-vscode/src/cli.ts index dc6f49de2..32b424b0e 100644 --- a/extensions/ql-vscode/src/cli.ts +++ b/extensions/ql-vscode/src/cli.ts @@ -605,7 +605,7 @@ export class CodeQLCliServer implements Disposable { if (target) subcommandArgs.push('--target', target); if (name) subcommandArgs.push('--name', name); subcommandArgs.push(archivePath); - + return await this.runCodeQlCliCommand(['database', 'unbundle'], subcommandArgs, `Extracting ${archivePath} to directory ${target}`); } @@ -805,8 +805,30 @@ export class CodeQLCliServer implements Disposable { return this.runJsonCodeQlCliCommand(['pack', 'install'], [dir], 'Installing pack dependencies'); } - async packBundle(dir: string, outputPath: string): Promise { - return this.runJsonCodeQlCliCommand(['pack', 'bundle'], ['-o', outputPath, dir], 'Bundling pack'); + async packBundle(dir: string, workspaceFolders: string[], outputPath: string, precompile = true): Promise { + const args = [ + '-o', + outputPath, + dir, + '--additional-packs', + workspaceFolders.join(path.delimiter) + ]; + if (!precompile && await this.cliConstraints.supportsNoPrecompile()) { + args.push('--no-precompile'); + } + + return this.runJsonCodeQlCliCommand(['pack', 'bundle'], args, 'Bundling pack'); + } + + async packPacklist(dir: string, includeQueries: boolean): Promise { + const args = includeQueries ? [dir] : ['--no-include-queries', dir]; + const results = await this.runJsonCodeQlCliCommand(['pack', 'packlist'], args, 'Generating the pack list'); + + if (await this.cliConstraints.usesNewPackPacklistLayout()) { + return (results as { paths: string[] }).paths; + } else { + return results as string[]; + } } async generateDil(qloFile: string, outFile: string): Promise { @@ -1057,6 +1079,12 @@ export function shouldDebugQueryServer() { && process.env.QUERY_SERVER_JAVA_DEBUG?.toLocaleLowerCase() !== 'false'; } +export function shouldDebugCliServer() { + return 'CLI_SERVER_JAVA_DEBUG' in process.env + && process.env.CLI_SERVER_JAVA_DEBUG !== '0' + && process.env.CLI_SERVER_JAVA_DEBUG?.toLocaleLowerCase() !== 'false'; +} + export class CliVersionConstraint { /** @@ -1096,6 +1124,16 @@ export class CliVersionConstraint { */ public static CLI_VERSION_WITH_DATABASE_UNBUNDLE = new SemVer('2.6.0'); + /** + * CLI version where the `--no-precompile` option for pack creation was introduced. + */ + public static CLI_VERSION_WITH_NO_PRECOMPILE = new SemVer('2.7.1'); + + /** + * CLI version where `pack packlist` layout changed from array to object + */ + public static CLI_VERSION_PACK_PACKLIST_LAYOUT_CHANGE = new SemVer('2.7.1'); + constructor(private readonly cli: CodeQLCliServer) { /**/ } @@ -1132,4 +1170,11 @@ export class CliVersionConstraint { return this.isVersionAtLeast(CliVersionConstraint.CLI_VERSION_WITH_DATABASE_UNBUNDLE); } + async supportsNoPrecompile() { + return this.isVersionAtLeast(CliVersionConstraint.CLI_VERSION_WITH_NO_PRECOMPILE); + } + + async usesNewPackPacklistLayout() { + return this.isVersionAtLeast(CliVersionConstraint.CLI_VERSION_PACK_PACKLIST_LAYOUT_CHANGE); + } } diff --git a/extensions/ql-vscode/src/config.ts b/extensions/ql-vscode/src/config.ts index d8d1a8b06..67e7f4721 100644 --- a/extensions/ql-vscode/src/config.ts +++ b/extensions/ql-vscode/src/config.ts @@ -304,7 +304,7 @@ const REMOTE_QUERIES_SETTING = new Setting('remoteQueries', ROOT_SETTING); /** * Lists of GitHub repositories that you want to query remotely via the "Run Remote query" command. * Note: This command is only available for internal users. - * + * * This setting should be a JSON object where each key is a user-specified name (string), * and the value is an array of GitHub repositories (of the form `/`). */ diff --git a/extensions/ql-vscode/src/extension.ts b/extensions/ql-vscode/src/extension.ts index 81dc308b8..54b80975a 100644 --- a/extensions/ql-vscode/src/extension.ts +++ b/extensions/ql-vscode/src/extension.ts @@ -716,13 +716,25 @@ async function activateWithInstalledDistribution( ); // The "runRemoteQuery" command is internal-only. ctx.subscriptions.push( - commandRunner('codeQL.runRemoteQuery', async ( + commandRunnerWithProgress('codeQL.runRemoteQuery', async ( + progress: ProgressCallback, + token: CancellationToken, uri: Uri | undefined ) => { if (isCanary()) { + progress({ + maxStep: 5, + step: 0, + message: 'Getting credentials' + }); const credentials = await Credentials.initialize(ctx); - await runRemoteQuery(cliServer, credentials, uri || window.activeTextEditor?.document.uri); + await runRemoteQuery(cliServer, credentials, uri || window.activeTextEditor?.document.uri, false, progress, token); + } else { + throw new Error('Remote queries require the CodeQL Canary version to run.'); } + }, { + title: 'Run Remote Query', + cancellable: true }) ); ctx.subscriptions.push( diff --git a/extensions/ql-vscode/src/helpers.ts b/extensions/ql-vscode/src/helpers.ts index 9caf7991b..d5156dd17 100644 --- a/extensions/ql-vscode/src/helpers.ts +++ b/extensions/ql-vscode/src/helpers.ts @@ -10,6 +10,7 @@ import { env } from 'vscode'; import { CodeQLCliServer, QlpacksInfo } from './cli'; +import { UserCancellationException } from './commandRunner'; import { logger } from './logging'; /** @@ -494,14 +495,25 @@ export async function findLanguage( void logger.log('Could not autodetect query language. Select language manually.'); } } - const availableLanguages = Object.keys(await cliServer.resolveLanguages()); + + // will be undefined if user cancels the quick pick. + return await askForLanguage(cliServer, false); +} + + +export async function askForLanguage(cliServer: CodeQLCliServer, throwOnEmpty = true): Promise { + const availableLanguages = Object.keys(await cliServer.resolveLanguages()).sort(); const language = await Window.showQuickPick( availableLanguages, { placeHolder: 'Select target language for your query', ignoreFocusOut: true } ); if (!language) { // This only happens if the user cancels the quick pick. - void showAndLogErrorMessage('Language not found. Language must be specified manually.'); + if (throwOnEmpty) { + throw new UserCancellationException('Cancelled.'); + } else { + void showAndLogErrorMessage('Language not found. Language must be specified manually.'); + } } return language; } diff --git a/extensions/ql-vscode/src/run-remote-query.ts b/extensions/ql-vscode/src/run-remote-query.ts index a3960795a..a142b550f 100644 --- a/extensions/ql-vscode/src/run-remote-query.ts +++ b/extensions/ql-vscode/src/run-remote-query.ts @@ -1,13 +1,15 @@ -import { QuickPickItem, Uri, window } from 'vscode'; +import { CancellationToken, QuickPickItem, Uri, window } from 'vscode'; import * as path from 'path'; import * as yaml from 'js-yaml'; import * as fs from 'fs-extra'; import * as tmp from 'tmp-promise'; -import { findLanguage, showAndLogErrorMessage, showAndLogInformationMessage, showInformationMessageWithAction } from './helpers'; +import { askForLanguage, findLanguage, getOnDiskWorkspaceFolders, showAndLogErrorMessage, showAndLogInformationMessage, showInformationMessageWithAction } from './helpers'; import { Credentials } from './authentication'; import * as cli from './cli'; import { logger } from './logging'; import { getRemoteControllerRepo, getRemoteRepositoryLists, setRemoteControllerRepo } from './config'; +import { tmpDir } from './run-queries'; +import { ProgressCallback, UserCancellationException } from './commandRunner'; interface Config { repositories: string[]; ref?: string; @@ -70,93 +72,244 @@ export async function getRepositories(): Promise { } } -async function generateQueryPack(cliServer: cli.CodeQLCliServer, queryFile: string): Promise { +/** + * Two possibilities: + * 1. There is no qlpack.yml in this directory. Assume this is a lone query and generate a synthetic qlpack for it. + * 2. There is a qlpack.yml in this directory. Assume this is a query pack and use the yml to pack the query before uploading it. + * + * @returns the entire qlpack as a base64 string. + */ +async function generateQueryPack(cliServer: cli.CodeQLCliServer, queryFile: string, queryPackDir: string, fallbackLanguage?: string): Promise<{ + base64Pack: string, + language: string +}> { + const originalPackRoot = path.dirname(queryFile); + // TODO this assumes that the qlpack.yml is in the same directory as the query file, but in reality, + // the file could be in a parent directory. + const targetQueryFileName = path.join(queryPackDir, path.basename(queryFile)); - const packRoot = path.dirname(queryFile); + // the server is expecting the query file to be named `query.ql`. Rename it here. + const renamedQueryFile = path.join(queryPackDir, 'query.ql'); - const bundlePath = await tmp.tmpName({ - postfix: '.tgz', - prefix: 'qlpack-', - }); + let language: string | undefined; + if (await fs.pathExists(path.join(originalPackRoot, 'qlpack.yml'))) { + // don't include ql files. We only want the queryFile to be copied. + const toCopy = await cliServer.packPacklist(originalPackRoot, false); - await cliServer.packBundle(packRoot, bundlePath); + // also copy the lock file (either new name or old name) and the query file itself. These are not included in the packlist. + [path.join(originalPackRoot, 'qlpack.lock.yml'), path.join(originalPackRoot, 'codeql-pack.lock.yml'), queryFile] + .forEach(aboslutePath => { + if (aboslutePath) { + toCopy.push(aboslutePath); + } + }); + void logger.log(`Copying ${toCopy.length} files to ${queryPackDir}`); + await fs.copy(originalPackRoot, queryPackDir, { + filter: (file: string) => + // copy file if it is in the packlist, or it is a parent directory of a file in the packlist + !!toCopy.find(f => f === file || f.startsWith(file + path.sep) + ) + }); + language = await findLanguage(cliServer, Uri.file(targetQueryFileName)); + + } else { + // open popup to ask for language if not already hardcoded + language = fallbackLanguage || await askForLanguage(cliServer); + + // copy only the query file to the query pack directory + // and generate a synthetic query pack + // TODO this has a limitation that query packs inside of a workspace will not resolve it's peer dependencies. + // Something to work on later. For now, we will only support query packs that are not in a workspace. + void logger.log(`Copying ${queryFile} to ${queryPackDir}`); + await fs.copy(queryFile, targetQueryFileName); + void logger.log('Generating synthetic query pack'); + const syntheticQueryPack = { + name: 'codeql-remote/query', + version: '1.0.0', + dependencies: { + [`codeql/${language}-all`]: '*', + } + }; + await fs.writeFile(path.join(queryPackDir, 'qlpack.yml'), yaml.safeDump(syntheticQueryPack)); + } + if (!language) { + throw new UserCancellationException('Could not determine language.'); + } + + await fs.rename(targetQueryFileName, renamedQueryFile); + + const bundlePath = await getPackedBundlePath(queryPackDir); + void logger.log(`Compiling and bundling query pack from ${queryPackDir} to ${bundlePath}. (This may take a while.)`); + await cliServer.packInstall(queryPackDir); + const workspaceFolders = getOnDiskWorkspaceFolders(); + await cliServer.packBundle(queryPackDir, workspaceFolders, bundlePath, false); const base64Pack = (await fs.readFile(bundlePath)).toString('base64'); - - await fs.unlink(bundlePath); - return base64Pack; + return { + base64Pack, + language + }; } -export async function runRemoteQuery(cliServer: cli.CodeQLCliServer, credentials: Credentials, uri?: Uri) { - if (!uri?.fsPath.endsWith('.ql')) { +async function createRemoteQueriesTempDirectory() { + const remoteQueryDir = await tmp.dir({ dir: tmpDir.name, unsafeCleanup: true }); + const queryPackDir = path.join(remoteQueryDir.path, 'query-pack'); + await fs.mkdirp(queryPackDir); + return { remoteQueryDir, queryPackDir }; +} + +async function getPackedBundlePath(queryPackDir: string) { + return tmp.tmpName({ + dir: path.dirname(queryPackDir), + postfix: 'generated.tgz', + prefix: 'qlpack', + }); +} + +export async function runRemoteQuery( + cliServer: cli.CodeQLCliServer, + credentials: Credentials, + uri: Uri | undefined, + dryRun: boolean, + progress: ProgressCallback, + token: CancellationToken +): Promise { + const { remoteQueryDir, queryPackDir } = await createRemoteQueriesTempDirectory(); + try { + if (!uri?.fsPath.endsWith('.ql')) { + throw new UserCancellationException('Not a CodeQL query file.'); + } + + progress({ + maxStep: 5, + step: 1, + message: 'Determining project list' + }); + + const queryFile = uri.fsPath; + const repositoriesFile = queryFile.substring(0, queryFile.length - '.ql'.length) + '.repositories'; + let ref: string | undefined; + // For the case of single file remote queries, use the language from the config in order to avoid the user having to select it. + let fallbackLanguage: string | undefined; + let repositories: string[] | undefined; + + progress({ + maxStep: 5, + step: 2, + message: 'Determining query target language' + }); + + // If the user has an explicit `.repositories` file, use that. + // Otherwise, prompt user to select repositories from the `codeQL.remoteQueries.repositoryLists` setting. + if (await fs.pathExists(repositoriesFile)) { + void logger.log(`Found '${repositoriesFile}'. Using information from that file to run ${queryFile}.`); + + const config = yaml.safeLoad(await fs.readFile(repositoriesFile, 'utf8')) as Config; + + ref = config.ref || 'main'; + fallbackLanguage = config.language; + repositories = config.repositories; + } else { + ref = 'main'; + repositories = await getRepositories(); + } + + if (!repositories || repositories.length === 0) { + // No error message needed, since `getRepositories` already displays one. + throw new UserCancellationException('No repositories to query.'); + } + + progress({ + maxStep: 5, + step: 3, + message: 'Determining controller repo' + }); + + // Get the controller repo from the config, if it exists. + // If it doesn't exist, prompt the user to enter it, and save that value to the config. + let controllerRepo: string | undefined; + controllerRepo = getRemoteControllerRepo(); + if (!controllerRepo || !REPO_REGEX.test(controllerRepo)) { + void logger.log(controllerRepo ? 'Invalid controller repository name.' : 'No controller repository defined.'); + controllerRepo = await window.showInputBox({ + title: 'Controller repository in which to display progress and results of remote queries', + placeHolder: '/', + prompt: 'Enter the name of a GitHub repository in the format /', + ignoreFocusOut: true, + }); + if (!controllerRepo) { + void showAndLogErrorMessage('No controller repository entered.'); + return; + } else if (!REPO_REGEX.test(controllerRepo)) { // Check if user entered invalid input + void showAndLogErrorMessage('Invalid repository format. Must be a valid GitHub repository in the format /.'); + return; + } + void logger.log(`Setting the controller repository as: ${controllerRepo}`); + await setRemoteControllerRepo(controllerRepo); + } + + void logger.log(`Using controller repository: ${controllerRepo}`); + const [owner, repo] = controllerRepo.split('/'); + + progress({ + maxStep: 5, + step: 4, + message: 'Bundling the query pack' + }); + + if (token.isCancellationRequested) { + throw new UserCancellationException('Cancelled'); + } + + const { base64Pack, language } = await generateQueryPack(cliServer, queryFile, queryPackDir, fallbackLanguage); + + if (token.isCancellationRequested) { + throw new UserCancellationException('Cancelled'); + } + + progress({ + maxStep: 5, + step: 5, + message: 'Sending request' + }); + + await runRemoteQueriesApiRequest(credentials, ref, language, repositories, owner, repo, base64Pack, dryRun); + + if (dryRun) { + return remoteQueryDir.path; + } else { + // don't return the path because it has been deleted + return; + } + + } finally { + if (dryRun) { + // If we are in a dry run keep the data around for debugging purposes. + void logger.log(`[DRY RUN] Not deleting ${queryPackDir}.`); + } else { + await remoteQueryDir.cleanup(); + } + } +} + +async function runRemoteQueriesApiRequest( + credentials: Credentials, + ref: string, + language: string, + repositories: string[], + owner: string, + repo: string, + queryPackBase64: string, + dryRun = false +): Promise { + + if (dryRun) { + void showAndLogInformationMessage('[DRY RUN] Would have sent request. See extension log for the payload.'); + void logger.log(JSON.stringify({ ref, language, repositories, owner, repo, queryPackBase64: queryPackBase64.substring(0, 100) + '... ' + queryPackBase64.length + ' bytes' })); return; } - const queryFile = uri.fsPath; - const query = await fs.readFile(queryFile, 'utf8'); - - const repositoriesFile = queryFile.substring(0, queryFile.length - '.ql'.length) + '.repositories'; - let ref: string | undefined; - let language: string | undefined; - let repositories: string[] | undefined; - - await cliServer.packInstall(path.dirname(queryFile)); - // If the user has an explicit `.repositories` file, use that. - // Otherwise, prompt user to select repositories from the `codeQL.remoteQueries.repositoryLists` setting. - if (await fs.pathExists(repositoriesFile)) { - void logger.log(`Found '${repositoriesFile}'. Using information from that file to run ${queryFile}.`); - - const config = yaml.safeLoad(await fs.readFile(repositoriesFile, 'utf8')) as Config; - - ref = config.ref || 'main'; - language = config.language || await findLanguage(cliServer, uri); - repositories = config.repositories; - } else { - ref = 'main'; - [language, repositories] = await Promise.all([findLanguage(cliServer, uri), getRepositories()]); - } - - if (!language) { - return; // No error message needed, since `findLanguage` already displays one. - } - - if (!repositories || repositories.length === 0) { - return; // No error message needed, since `getRepositories` already displays one. - } - - // Get the controller repo from the config, if it exists. - // If it doesn't exist, prompt the user to enter it, and save that value to the config. - let controllerRepo: string | undefined; - controllerRepo = getRemoteControllerRepo(); - if (!controllerRepo || !REPO_REGEX.test(controllerRepo)) { - void logger.log(controllerRepo ? 'Invalid controller repository name.' : 'No controller repository defined.'); - controllerRepo = await window.showInputBox({ - title: 'Controller repository in which to display progress and results of remote queries', - placeHolder: '/', - prompt: 'Enter the name of a GitHub repository in the format /', - ignoreFocusOut: true, - }); - if (!controllerRepo) { - void showAndLogErrorMessage('No controller repository entered.'); - return; - } else if (!REPO_REGEX.test(controllerRepo)) { // Check if user entered invalid input - void showAndLogErrorMessage('Invalid repository format. Must be a valid GitHub repository in the format /.'); - return; - } - void logger.log(`Setting the controller repository as: ${controllerRepo}`); - await setRemoteControllerRepo(controllerRepo); - } - - void logger.log(`Using controller repository: ${controllerRepo}`); - const [owner, repo] = controllerRepo.split('/'); - - const queryPackBase64 = await generateQueryPack(cliServer, queryFile); - await runRemoteQueriesApiRequest(credentials, ref, language, repositories, query, owner, repo, queryPackBase64); -} - -async function runRemoteQueriesApiRequest(credentials: Credentials, ref: string, language: string, repositories: string[], query: string, owner: string, repo: string, queryPackBase64: string): Promise { - const octokit = await credentials.getOctokit(); - try { + const octokit = await credentials.getOctokit(); await octokit.request( 'POST /repos/:owner/:repo/code-scanning/codeql/queries', { @@ -166,20 +319,29 @@ async function runRemoteQueriesApiRequest(credentials: Credentials, ref: string, ref, language, repositories, - query, - queryPack: queryPackBase64, + query_pack: queryPackBase64, } } ); void showAndLogInformationMessage(`Successfully scheduled runs. [Click here to see the progress](https://github.com/${owner}/${repo}/actions).`); } catch (error) { - await attemptRerun(error, credentials, ref, language, repositories, query, owner, repo, queryPackBase64); + await attemptRerun(error, credentials, ref, language, repositories, owner, repo, queryPackBase64, dryRun); } } /** Attempts to rerun the query on only the valid repositories */ -export async function attemptRerun(error: any, credentials: Credentials, ref: string, language: string, repositories: string[], query: string, owner: string, repo: string, queryPackBase64: string) { +export async function attemptRerun( + error: any, + credentials: Credentials, + ref: string, + language: string, + repositories: string[], + owner: string, + repo: string, + queryPackBase64: string, + dryRun = false +) { if (typeof error.message === 'string' && error.message.includes('Some repositories were invalid')) { const invalidRepos = error?.response?.data?.invalid_repos || []; const reposWithoutDbUploads = error?.response?.data?.repos_without_db_uploads || []; @@ -202,11 +364,9 @@ export async function attemptRerun(error: any, credentials: Credentials, ref: st if (rerunQuery) { const validRepositories = repositories.filter(r => !invalidRepos.includes(r) && !reposWithoutDbUploads.includes(r)); void logger.log(`Rerunning query on set of valid repositories: ${JSON.stringify(validRepositories)}`); - await runRemoteQueriesApiRequest(credentials, ref, language, validRepositories, query, owner, repo, queryPackBase64); + await runRemoteQueriesApiRequest(credentials, ref, language, validRepositories, owner, repo, queryPackBase64, dryRun); } - } else { void showAndLogErrorMessage(error); } - } diff --git a/extensions/ql-vscode/src/vscode-tests/cli-integration/data-remote-no-qlpack/in-pack.ql b/extensions/ql-vscode/src/vscode-tests/cli-integration/data-remote-no-qlpack/in-pack.ql new file mode 100644 index 000000000..7ca7d397b --- /dev/null +++ b/extensions/ql-vscode/src/vscode-tests/cli-integration/data-remote-no-qlpack/in-pack.ql @@ -0,0 +1,2 @@ +// This file should not be included the remote query pack. +select 1 diff --git a/extensions/ql-vscode/src/vscode-tests/cli-integration/data-remote-no-qlpack/lib.qll b/extensions/ql-vscode/src/vscode-tests/cli-integration/data-remote-no-qlpack/lib.qll new file mode 100644 index 000000000..08a900299 --- /dev/null +++ b/extensions/ql-vscode/src/vscode-tests/cli-integration/data-remote-no-qlpack/lib.qll @@ -0,0 +1,3 @@ +int number() { + result = 1 +} diff --git a/extensions/ql-vscode/src/vscode-tests/cli-integration/data-remote-no-qlpack/not-in-pack.ql b/extensions/ql-vscode/src/vscode-tests/cli-integration/data-remote-no-qlpack/not-in-pack.ql new file mode 100644 index 000000000..a0f39af72 --- /dev/null +++ b/extensions/ql-vscode/src/vscode-tests/cli-integration/data-remote-no-qlpack/not-in-pack.ql @@ -0,0 +1,4 @@ +import javascript +import lib + +select number() diff --git a/extensions/ql-vscode/src/vscode-tests/cli-integration/data-remote-qlpack/in-pack.ql b/extensions/ql-vscode/src/vscode-tests/cli-integration/data-remote-qlpack/in-pack.ql new file mode 100644 index 000000000..a0f39af72 --- /dev/null +++ b/extensions/ql-vscode/src/vscode-tests/cli-integration/data-remote-qlpack/in-pack.ql @@ -0,0 +1,4 @@ +import javascript +import lib + +select number() diff --git a/extensions/ql-vscode/src/vscode-tests/cli-integration/data-remote-qlpack/lib.qll b/extensions/ql-vscode/src/vscode-tests/cli-integration/data-remote-qlpack/lib.qll new file mode 100644 index 000000000..08a900299 --- /dev/null +++ b/extensions/ql-vscode/src/vscode-tests/cli-integration/data-remote-qlpack/lib.qll @@ -0,0 +1,3 @@ +int number() { + result = 1 +} diff --git a/extensions/ql-vscode/src/vscode-tests/cli-integration/data-remote-qlpack/not-in-pack.ql b/extensions/ql-vscode/src/vscode-tests/cli-integration/data-remote-qlpack/not-in-pack.ql new file mode 100644 index 000000000..7ca7d397b --- /dev/null +++ b/extensions/ql-vscode/src/vscode-tests/cli-integration/data-remote-qlpack/not-in-pack.ql @@ -0,0 +1,2 @@ +// This file should not be included the remote query pack. +select 1 diff --git a/extensions/ql-vscode/src/vscode-tests/cli-integration/data-remote-qlpack/qlpack.yml b/extensions/ql-vscode/src/vscode-tests/cli-integration/data-remote-qlpack/qlpack.yml new file mode 100644 index 000000000..d715c36c3 --- /dev/null +++ b/extensions/ql-vscode/src/vscode-tests/cli-integration/data-remote-qlpack/qlpack.yml @@ -0,0 +1,5 @@ +name: github/remote-query-pack +version: 0.0.0 +extractor: javascript +dependencies: + codeql/javascript-all: '*' diff --git a/extensions/ql-vscode/src/vscode-tests/cli-integration/index.ts b/extensions/ql-vscode/src/vscode-tests/cli-integration/index.ts index 681137289..d6fa49763 100644 --- a/extensions/ql-vscode/src/vscode-tests/cli-integration/index.ts +++ b/extensions/ql-vscode/src/vscode-tests/cli-integration/index.ts @@ -1,6 +1,10 @@ -import 'mocha'; -import 'sinon-chai'; import { runTestsInDirectory } from '../index-template'; +import 'mocha'; +import * as sinonChai from 'sinon-chai'; +import * as chai from 'chai'; +import * as chaiAsPromised from 'chai-as-promised'; +chai.use(chaiAsPromised); +chai.use(sinonChai); // The simple database used throughout the tests export function run(): Promise { diff --git a/extensions/ql-vscode/src/vscode-tests/cli-integration/run-remote-query.test.ts b/extensions/ql-vscode/src/vscode-tests/cli-integration/run-remote-query.test.ts new file mode 100644 index 000000000..bd2e81be2 --- /dev/null +++ b/extensions/ql-vscode/src/vscode-tests/cli-integration/run-remote-query.test.ts @@ -0,0 +1,164 @@ +import { assert, expect } from 'chai'; +import * as path from 'path'; +import * as sinon from 'sinon'; +import { CancellationToken, extensions, QuickPickItem, Uri, window } from 'vscode'; +import 'mocha'; +import * as fs from 'fs-extra'; +import * as yaml from 'js-yaml'; + +import { runRemoteQuery } from '../../run-remote-query'; +import { Credentials } from '../../authentication'; +import { CodeQLCliServer } from '../../cli'; +import { CodeQLExtensionInterface } from '../../extension'; +import { setRemoteControllerRepo } from '../../config'; +import { UserCancellationException } from '../../commandRunner'; + +describe('Remote queries', function() { + const baseDir = path.join(__dirname, '../../../src/vscode-tests/cli-integration'); + + let sandbox: sinon.SinonSandbox; + + // up to 3 minutes per test + this.timeout(3 * 60 * 1000); + + let cli: CodeQLCliServer; + let credentials: Credentials = {} as unknown as Credentials; + let token: CancellationToken; + let progress: sinon.SinonSpy; + let showQuickPickSpy: sinon.SinonStub; + + beforeEach(async () => { + sandbox = sinon.createSandbox(); + + const extension = await extensions.getExtension>('GitHub.vscode-codeql')!.activate(); + if ('cliServer' in extension) { + cli = extension.cliServer; + } else { + throw new Error('Extension not initialized. Make sure cli is downloaded and installed properly.'); + } + credentials = {} as unknown as Credentials; + token = { + isCancellationRequested: false + } as unknown as CancellationToken; + + progress = sandbox.spy(); + // Should not have asked for a language + showQuickPickSpy = sandbox.stub(window, 'showQuickPick') + .onFirstCall().resolves({ repoList: ['github/vscode-codeql'] } as unknown as QuickPickItem) + .onSecondCall().resolves('javascript' as unknown as QuickPickItem); + + // always run in the vscode-codeql repo + void setRemoteControllerRepo('github/vscode-codeql'); + }); + + afterEach(() => { + sandbox.restore(); + }); + + it('should run a remote query that is part of a qlpack', async () => { + const fileUri = getFile('data-remote-qlpack/in-pack.ql'); + + const queryPackRootDir = (await runRemoteQuery(cli, credentials, fileUri, true, progress, token))!; + + // to retrieve the list of repositories + expect(showQuickPickSpy).to.have.been.calledOnce; + + // check a few files that we know should exist and others that we know should not + + // the tarball to deliver to the server + expect(fs.readdirSync(queryPackRootDir).find(f => f.startsWith('qlpack-') && f.endsWith('-generated.tgz'))).not.to.be.undefined; + + const queryPackDir = path.join(queryPackRootDir, 'query-pack'); + // in-pack.ql renamed to query.ql + expect(fs.existsSync(path.join(queryPackDir, 'query.ql'))).to.be.true; + expect(fs.existsSync(path.join(queryPackDir, 'lib.qll'))).to.be.true; + expect(fs.existsSync(path.join(queryPackDir, 'qlpack.yml'))).to.be.true; + expect(fs.existsSync( + // depending on the cli version, we should have one of these files + path.join(queryPackDir, 'qlpack.lock.yml') || path.join(queryPackDir, 'codeql-pack.lock.yml') + )).to.be.true; + expect(fs.existsSync(path.join(queryPackDir, 'not-in-pack.ql'))).to.be.false; + + // the compiled pack + const compiledPackDir = path.join(queryPackDir, '.codeql/pack/github/remote-query-pack/0.0.0/'); + expect(fs.existsSync(path.join(compiledPackDir, 'query.ql'))).to.be.true; + expect(fs.existsSync(path.join(compiledPackDir, 'lib.qll'))).to.be.true; + expect(fs.existsSync(path.join(compiledPackDir, 'qlpack.yml'))).to.be.true; + expect(fs.existsSync( + // depending on the cli version, we should have one of these files + path.join(compiledPackDir, 'qlpack.lock.yml') || path.join(queryPackDir, 'codeql-pack.lock.yml') + )).to.be.true; + expect(fs.existsSync(path.join(compiledPackDir, 'not-in-pack.ql'))).to.be.false; + + // dependencies + const libraryDir = path.join(compiledPackDir, '.codeql/libraries/codeql'); + const packNames = fs.readdirSync(libraryDir).sort(); + expect(packNames).to.deep.equal(['javascript-all', 'javascript-upgrades']); + }); + + it('should run a remote query that is not part of a qlpack', async () => { + const fileUri = getFile('data-remote-no-qlpack/in-pack.ql'); + + const queryPackRootDir = (await runRemoteQuery(cli, credentials, fileUri, true, progress, token))!; + + // to retrieve the list of repositories + // and a second time to ask for the language + expect(showQuickPickSpy).to.have.been.calledTwice; + + // check a few files that we know should exist and others that we know should not + + // the tarball to deliver to the server + expect(fs.readdirSync(queryPackRootDir).find(f => f.startsWith('qlpack-') && f.endsWith('-generated.tgz'))).not.to.be.undefined; + + const queryPackDir = path.join(queryPackRootDir, 'query-pack'); + // in-pack.ql renamed to query.ql + expect(fs.existsSync(path.join(queryPackDir, 'query.ql'))).to.be.true; + expect(fs.existsSync(path.join(queryPackDir, 'qlpack.yml'))).to.be.true; + expect(fs.existsSync( + // depending on the cli version, we should have one of these files + path.join(queryPackDir, 'qlpack.lock.yml') || path.join(queryPackDir, 'codeql-pack.lock.yml') + )).to.be.true; + expect(fs.existsSync(path.join(queryPackDir, 'lib.qll'))).to.be.false; + expect(fs.existsSync(path.join(queryPackDir, 'not-in-pack.ql'))).to.be.false; + + // the compiled pack + const compiledPackDir = path.join(queryPackDir, '.codeql/pack/codeql-remote/query/1.0.0/'); + expect(fs.existsSync(path.join(compiledPackDir, 'query.ql'))).to.be.true; + expect(fs.existsSync(path.join(compiledPackDir, 'qlpack.yml'))).to.be.true; + expect(fs.existsSync( + // depending on the cli version, we should have one of these files + path.join(compiledPackDir, 'qlpack.lock.yml') || path.join(queryPackDir, 'codeql-pack.lock.yml') + )).to.be.true; + expect(fs.existsSync(path.join(compiledPackDir, 'lib.qll'))).to.be.false; + expect(fs.existsSync(path.join(compiledPackDir, 'not-in-pack.ql'))).to.be.false; + // should have generated a correct qlpack file + const qlpackContents: any = yaml.safeLoad(fs.readFileSync(path.join(compiledPackDir, 'qlpack.yml'), 'utf8')); + expect(qlpackContents.name).to.equal('codeql-remote/query'); + expect(qlpackContents.version).to.equal('1.0.0'); + expect(qlpackContents.dependencies?.['codeql/javascript-all']).to.equal('*'); + + // dependencies + const libraryDir = path.join(compiledPackDir, '.codeql/libraries/codeql'); + const packNames = fs.readdirSync(libraryDir).sort(); + expect(packNames).to.deep.equal(['javascript-all', 'javascript-upgrades']); + }); + + it('should cancel a run before uploading', async () => { + const fileUri = getFile('data-remote-no-qlpack/in-pack.ql'); + + const promise = runRemoteQuery(cli, credentials, fileUri, true, progress, token); + + token.isCancellationRequested = true; + + try { + await promise; + assert.fail('should have thrown'); + } catch (e) { + expect(e).to.be.instanceof(UserCancellationException); + } + }); + + function getFile(file: string): Uri { + return Uri.file(path.join(baseDir, file)); + } +}); diff --git a/extensions/ql-vscode/src/vscode-tests/ensureCli.ts b/extensions/ql-vscode/src/vscode-tests/ensureCli.ts index cbe8d25f9..a6bebae5c 100644 --- a/extensions/ql-vscode/src/vscode-tests/ensureCli.ts +++ b/extensions/ql-vscode/src/vscode-tests/ensureCli.ts @@ -44,7 +44,7 @@ const _10MB = _1MB * 10; // CLI version to test. Hard code the latest as default. And be sure // to update the env if it is not otherwise set. -const CLI_VERSION = process.env.CLI_VERSION || 'v2.6.3'; +const CLI_VERSION = process.env.CLI_VERSION || 'v2.7.0'; process.env.CLI_VERSION = CLI_VERSION; // Base dir where CLIs will be downloaded into