Skip to content

Add integration smoke test #481

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 14 commits into from
Oct 9, 2021
2 changes: 1 addition & 1 deletion .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
11 changes: 6 additions & 5 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
out
node_modules
.vscode-test
.DS_Store
dist
out
node_modules
.vscode-test
test-workspace
.DS_Store
dist
*.vsix
8 changes: 7 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down Expand Up @@ -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",
Expand Down
10 changes: 3 additions & 7 deletions src/extension.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
'use strict';
import * as os from 'os';
import * as path from 'path';
import {
commands,
Expand All @@ -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,
Expand Down Expand Up @@ -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`);
}
Expand Down
31 changes: 21 additions & 10 deletions src/hlsBinaries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 */
Expand Down Expand Up @@ -102,7 +102,8 @@ async function getProjectGhcVersion(
context: ExtensionContext,
logger: Logger,
dir: string,
release: IRelease
release: IRelease,
storagePath: string
): Promise<string> {
const title: string = 'Working out the project GHC version. This might take a while...';
logger.info(title);
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -204,7 +205,7 @@ async function getProjectGhcVersion(
return callWrapper(downloadedWrapper);
}

async function getLatestReleaseMetadata(context: ExtensionContext): Promise<IRelease | null> {
async function getLatestReleaseMetadata(context: ExtensionContext, storagePath: string): Promise<IRelease | null> {
const releasesUrl = workspace.getConfiguration('haskell').releasesURL
? url.parse(workspace.getConfiguration('haskell').releasesURL)
: undefined;
Expand All @@ -218,7 +219,7 @@ async function getLatestReleaseMetadata(context: ExtensionContext): Promise<IRel
path: '/repos/haskell/haskell-language-server/releases',
};

const offlineCache = path.join(context.globalStoragePath, 'latestApprovedRelease.cache.json');
const offlineCache = path.join(storagePath, 'latestApprovedRelease.cache.json');

async function readCachedReleaseData(): Promise<IRelease | null> {
try {
Expand Down Expand Up @@ -293,8 +294,18 @@ export async function downloadHaskellLanguageServer(
): Promise<string | null> {
// 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();
Expand All @@ -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;
Expand All @@ -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();
Expand Down Expand Up @@ -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);
Expand Down
11 changes: 10 additions & 1 deletion src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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;
}
11 changes: 8 additions & 3 deletions test/runTest.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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);
Expand Down
108 changes: 95 additions & 13 deletions test/suite/extension.test.ts
Original file line number Diff line number Diff line change
@@ -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<any>) {
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<vscode.Uri>((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();
});
});
1 change: 1 addition & 0 deletions test/suite/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ export async function run(): Promise<void> {
// Create the mocha test
const mocha = new Mocha({
ui: 'tdd',
timeout: 90000,
});
mocha.useColors(true);

Expand Down