diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 75975398..a2b4e811 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -10,7 +10,7 @@ jobs: build: strategy: matrix: - os: [macos-latest, ubuntu-latest, windows-latest] + os: [macos-11, ubuntu-latest, windows-latest] runs-on: ${{ matrix.os }} steps: - name: Checkout diff --git a/.gitignore b/.gitignore index ee361aa9..4bb73a38 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ -out -node_modules -.vscode-test -.DS_Store -dist +out +node_modules +.vscode-test +test-workspace +.DS_Store +dist *.vsix \ No newline at end of file diff --git a/package.json b/package.json index 1bd6fe26..d3b34122 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "haskell", "displayName": "Haskell", "description": "Haskell language support powered by the Haskell Language Server", - "version": "1.7.1", + "version": "1.7.2", "license": "MIT", "publisher": "haskell", "engines": { @@ -126,6 +126,12 @@ "default": "", "description": "An optional URL to override where to check for haskell-language-server releases" }, + "haskell.releasesDownloadStoragePath": { + "scope": "resource", + "type": "string", + "default": "", + "markdownDescription": "An optional path where downloaded binaries will be stored. Check the default value [here](https://github.com/haskell/vscode-haskell#downloaded-binaries)" + }, "haskell.serverExecutablePath": { "scope": "resource", "type": "string", diff --git a/src/extension.ts b/src/extension.ts index 3c5c63d5..592e858f 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -1,5 +1,4 @@ 'use strict'; -import * as os from 'os'; import * as path from 'path'; import { commands, @@ -24,7 +23,7 @@ import { CommandNames } from './commands/constants'; import { ImportIdentifier } from './commands/importIdentifier'; import { DocsBrowser } from './docsBrowser'; import { downloadHaskellLanguageServer } from './hlsBinaries'; -import { executableExists, ExtensionLogger } from './utils'; +import { executableExists, ExtensionLogger, resolvePathPlaceHolders } from './utils'; // The current map of documents & folders to language servers. // It may be null to indicate that we are in the process of launching a server, @@ -103,12 +102,9 @@ function findManualExecutable(logger: Logger, uri: Uri, folder?: WorkspaceFolder return null; } logger.info(`Trying to find the server executable in: ${exePath}`); - // Substitute path variables with their corresponding locations. - exePath = exePath.replace('${HOME}', os.homedir).replace('${home}', os.homedir).replace(/^~/, os.homedir); - if (folder) { - exePath = exePath.replace('${workspaceFolder}', folder.uri.path).replace('${workspaceRoot}', folder.uri.path); - } + exePath = resolvePathPlaceHolders(exePath, folder); logger.info(`Location after path variables subsitution: ${exePath}`); + if (!executableExists(exePath)) { throw new Error(`serverExecutablePath is set to ${exePath} but it doesn't exist and it is not on the PATH`); } diff --git a/src/hlsBinaries.ts b/src/hlsBinaries.ts index fbab95d5..7166cb9b 100644 --- a/src/hlsBinaries.ts +++ b/src/hlsBinaries.ts @@ -7,7 +7,7 @@ import * as url from 'url'; import { promisify } from 'util'; import { env, ExtensionContext, ProgressLocation, Uri, window, workspace, WorkspaceFolder } from 'vscode'; import { Logger } from 'vscode-languageclient'; -import { downloadFile, executableExists, httpsGetSilently } from './utils'; +import { downloadFile, executableExists, httpsGetSilently, resolvePathPlaceHolders } from './utils'; import * as validate from './validation'; /** GitHub API release */ @@ -102,7 +102,8 @@ async function getProjectGhcVersion( context: ExtensionContext, logger: Logger, dir: string, - release: IRelease + release: IRelease, + storagePath: string ): Promise { const title: string = 'Working out the project GHC version. This might take a while...'; logger.info(title); @@ -174,7 +175,7 @@ async function getProjectGhcVersion( // Otherwise search to see if we previously downloaded the wrapper const wrapperName = `haskell-language-server-wrapper-${release.tag_name}-${process.platform}${exeExt}`; - const downloadedWrapper = path.join(context.globalStoragePath, wrapperName); + const downloadedWrapper = path.join(storagePath, wrapperName); if (executableExists(downloadedWrapper)) { return callWrapper(downloadedWrapper); @@ -204,7 +205,7 @@ async function getProjectGhcVersion( return callWrapper(downloadedWrapper); } -async function getLatestReleaseMetadata(context: ExtensionContext): Promise { +async function getLatestReleaseMetadata(context: ExtensionContext, storagePath: string): Promise { const releasesUrl = workspace.getConfiguration('haskell').releasesURL ? url.parse(workspace.getConfiguration('haskell').releasesURL) : undefined; @@ -218,7 +219,7 @@ async function getLatestReleaseMetadata(context: ExtensionContext): Promise { try { @@ -293,8 +294,18 @@ export async function downloadHaskellLanguageServer( ): Promise { // Make sure to create this before getProjectGhcVersion logger.info('Downloading haskell-language-server'); - if (!fs.existsSync(context.globalStoragePath)) { - fs.mkdirSync(context.globalStoragePath); + + let storagePath: string | undefined = await workspace.getConfiguration('haskell').get('releasesDownloadStoragePath'); + + if (!storagePath) { + storagePath = context.globalStorageUri.fsPath; + } else { + storagePath = resolvePathPlaceHolders(storagePath); + } + logger.info(`Using ${storagePath} to store downloaded binaries`); + + if (!fs.existsSync(storagePath)) { + fs.mkdirSync(storagePath); } const githubOS = getGithubOS(); @@ -305,7 +316,7 @@ export async function downloadHaskellLanguageServer( } logger.info('Fetching the latest release from GitHub or from cache'); - const release = await getLatestReleaseMetadata(context); + const release = await getLatestReleaseMetadata(context, storagePath); if (!release) { let message = "Couldn't find any pre-built haskell-language-server binaries"; const updateBehaviour = workspace.getConfiguration('haskell').get('updateBehavior') as UpdateBehaviour; @@ -320,7 +331,7 @@ export async function downloadHaskellLanguageServer( const dir: string = folder?.uri?.fsPath ?? path.dirname(resource.fsPath); let ghcVersion: string; try { - ghcVersion = await getProjectGhcVersion(context, logger, dir, release); + ghcVersion = await getProjectGhcVersion(context, logger, dir, release, storagePath); } catch (error) { if (error instanceof MissingToolError) { const link = error.installLink(); @@ -354,7 +365,7 @@ export async function downloadHaskellLanguageServer( } const serverName = `haskell-language-server-${release.tag_name}-${process.platform}-${ghcVersion}${exeExt}`; - const binaryDest = path.join(context.globalStoragePath, serverName); + const binaryDest = path.join(storagePath, serverName); const title = `Downloading haskell-language-server ${release.tag_name} for GHC ${ghcVersion}`; logger.info(title); diff --git a/src/utils.ts b/src/utils.ts index 5c8a9b9f..c79025a5 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -4,10 +4,11 @@ import * as child_process from 'child_process'; import * as fs from 'fs'; import * as http from 'http'; import * as https from 'https'; +import * as os from 'os'; import { extname } from 'path'; import * as url from 'url'; import { promisify } from 'util'; -import { OutputChannel, ProgressLocation, window } from 'vscode'; +import { OutputChannel, ProgressLocation, window, WorkspaceFolder } from 'vscode'; import { Logger } from 'vscode-languageclient'; import * as yazul from 'yauzl'; import { createGunzip } from 'zlib'; @@ -261,3 +262,11 @@ export function executableExists(exe: string): boolean { const out = child_process.spawnSync(cmd, [exe]); return out.status === 0 || (isWindows && fs.existsSync(exe)); } + +export function resolvePathPlaceHolders(path: string, folder?: WorkspaceFolder) { + path = path.replace('${HOME}', os.homedir).replace('${home}', os.homedir).replace(/^~/, os.homedir); + if (folder) { + path = path.replace('${workspaceFolder}', folder.uri.path).replace('${workspaceRoot}', folder.uri.path); + } + return path; +} diff --git a/test/runTest.ts b/test/runTest.ts index c1c1b350..111b4a1b 100644 --- a/test/runTest.ts +++ b/test/runTest.ts @@ -1,4 +1,5 @@ import * as cp from 'child_process'; +import * as fs from 'fs'; import * as path from 'path'; import { downloadAndUnzipVSCode, resolveCliPathFromVSCodeExecutablePath, runTests } from '@vscode/test-electron'; @@ -26,14 +27,18 @@ async function main() { // Passed to --extensionTestsPath const extensionTestsPath = path.resolve(__dirname, './suite/index'); + const testWorkspace = path.resolve(__dirname, '../../test-workspace'); + + if (!fs.existsSync(testWorkspace)) { + fs.mkdirSync(testWorkspace); + } + // Download VS Code, unzip it and run the integration test await runTests({ vscodeExecutablePath, extensionDevelopmentPath, extensionTestsPath, - launchArgs: [ - // '--disable-extensions' - ], + launchArgs: [testWorkspace], }); } catch (err) { console.error(err); diff --git a/test/suite/extension.test.ts b/test/suite/extension.test.ts index ca62e47d..ac2e118c 100644 --- a/test/suite/extension.test.ts +++ b/test/suite/extension.test.ts @@ -1,26 +1,108 @@ +// tslint:disable: no-console import * as assert from 'assert'; - -// You can import and use all API from the 'vscode' module -// as well as import your extension to test it +import * as os from 'os'; +import * as path from 'path'; +import { TextEncoder } from 'util'; import * as vscode from 'vscode'; -// import * as haskell from '../../extension'; +import { CommandNames } from '../../src/commands/constants'; + +function getExtension() { + return vscode.extensions.getExtension('haskell.haskell'); +} + +async function delay(ms: number) { + return new Promise((resolve) => setTimeout(() => resolve(false), ms)); +} + +async function withTimeout(seconds: number, f: Promise) { + return Promise.race([f, delay(seconds * 1000)]); +} + +function getHaskellConfig() { + return vscode.workspace.getConfiguration('haskell'); +} + +function getWorkspaceRoot() { + return vscode.workspace.workspaceFolders![0]; +} + +function getWorkspaceFile(name: string) { + const wsroot = getWorkspaceRoot().uri; + return wsroot.with({ path: path.posix.join(wsroot.path, name) }); +} -function getExtension(extId: string) { - return vscode.extensions.getExtension(extId); +async function deleteWorkspaceFiles() { + const dirContents = await vscode.workspace.fs.readDirectory(getWorkspaceRoot().uri); + console.log(`Deleting test ws contents: ${dirContents}`); + dirContents.forEach(async ([name, type]) => { + const uri: vscode.Uri = getWorkspaceFile(name); + console.log(`Deleting ${uri}`); + await vscode.workspace.fs.delete(getWorkspaceFile(name), { recursive: true }); + }); } suite('Extension Test Suite', () => { + const disposables: vscode.Disposable[] = []; + + async function existsWorkspaceFile(pattern: string, pred?: (uri: vscode.Uri) => boolean) { + const relPath: vscode.RelativePattern = new vscode.RelativePattern(getWorkspaceRoot(), pattern); + const watcher = vscode.workspace.createFileSystemWatcher(relPath); + disposables.push(watcher); + return new Promise((resolve) => { + watcher.onDidCreate((uri) => { + console.log(`Created: ${uri}`); + if (!pred || pred(uri)) { + resolve(uri); + } + }); + }); + } + vscode.window.showInformationMessage('Start all tests.'); + suiteSetup(async () => { + await deleteWorkspaceFiles(); + await getHaskellConfig().update('logFile', 'hls.log'); + await getHaskellConfig().update('trace.server', 'messages'); + await getHaskellConfig().update('releasesDownloadStoragePath', path.normalize(getWorkspaceFile('bin').fsPath)); + const contents = new TextEncoder().encode('main = putStrLn "hi vscode tests"'); + await vscode.workspace.fs.writeFile(getWorkspaceFile('Main.hs'), contents); + }); + test('Extension should be present', () => { - assert.ok(getExtension('haskell.haskell')); + assert.ok(getExtension()); }); - test('should activate', () => { - return getExtension('haskell.haskell') - ?.activate() - .then(() => { - assert.ok(true); - }); + test('Extension should activate', async () => { + await getExtension()?.activate(); + assert.ok(true); + }); + + test('HLS executables should be downloaded', async () => { + await vscode.workspace.openTextDocument(getWorkspaceFile('Main.hs')); + const exeExt = os.platform.toString() === 'win32' ? '.exe' : ''; + console.log('Testing wrapper'); + const pred = (uri: vscode.Uri) => !['download', 'gz', 'zip'].includes(path.extname(uri.fsPath)); + assert.ok( + await withTimeout(30, existsWorkspaceFile(`bin/haskell-language-server-wrapper*${exeExt}`, pred)), + 'The wrapper executable was not downloaded in 30 seconds' + ); + console.log('Testing server'); + assert.ok( + await withTimeout(60, existsWorkspaceFile(`bin/haskell-language-server-[1-9]*${exeExt}`, pred)), + 'The server executable was not downloaded in 60 seconds' + ); + }); + + test('Server log should be created', async () => { + await vscode.workspace.openTextDocument(getWorkspaceFile('Main.hs')); + assert.ok(await withTimeout(30, existsWorkspaceFile('hls.log')), 'Server log not created in 30 seconds'); + }); + + suiteTeardown(async () => { + disposables.forEach((d) => d.dispose()); + await vscode.commands.executeCommand(CommandNames.StopServerCommandName); + delay(5); // to give time to shutdown server + await deleteWorkspaceFiles(); }); }); diff --git a/test/suite/index.ts b/test/suite/index.ts index 6df7da45..ec26a951 100644 --- a/test/suite/index.ts +++ b/test/suite/index.ts @@ -6,6 +6,7 @@ export async function run(): Promise { // Create the mocha test const mocha = new Mocha({ ui: 'tdd', + timeout: 90000, }); mocha.useColors(true);