diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index f26887f..0402db9 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -27,13 +27,28 @@ jobs: - name: Checkout uses: actions/checkout@v4 - - name: Install Node.js + - name: Install Node.js + if: runner.os != 'Linux' uses: actions/setup-node@v4 with: node-version: lts/* + - name: Install Node.js via NVM (Linux) + if: runner.os == 'Linux' + shell: bash + run: | + export NVM_DIR="$HOME/.nvm" + source "$NVM_DIR/nvm.sh" + echo $NVM_DIR >> $GITHUB_PATH + echo "NVM_DIR=$NVM_DIR" >> $GITHUB_ENV + nvm install $(cat test-workspaces/nvm/.nvmrc) + nvm install lts/* + - name: Install dependencies - run: npm install + run: | + node --version + npm --version + npm install - name: Compile run: npm run compile:test @@ -70,11 +85,12 @@ jobs: if: always() run: npm run lint - - uses: dorny/test-reporter@v1 + - uses: dorny/test-reporter@1a288b62f8b75c0f433cbfdbc2e4800fbae50bd7 if: ${{ (success() || failure()) && github.event.pull_request.head.repo.full_name == github.repository }} with: name: VS Code Test Results (${{matrix.os}}, ${{matrix.vscode-version}}, ${{matrix.vscode-platform}}) path: 'test-results/*.json' + use-actions-summary: 'true' reporter: mocha-json - uses: actions/upload-artifact@v4 diff --git a/.vscode-test.mjs b/.vscode-test.mjs index 54de15c..cc32ef6 100644 --- a/.vscode-test.mjs +++ b/.vscode-test.mjs @@ -3,7 +3,6 @@ import * as fs from 'fs'; import * as path from 'path'; import { fileURLToPath } from 'url'; - const dirname = fileURLToPath(new URL('.', import.meta.url)); const integrationTestDir = path.join(dirname, 'out/test/integration'); const workspaceBaseDir = path.join(dirname, 'test-workspaces'); @@ -15,8 +14,8 @@ let extensionDevelopmentPath = ''; const testMode = process.env.TEST_MODE ?? 'normal'; +const tempDir = process.env.TEST_TEMP ?? path.join(dirname, 'tmp'); if (testMode === 'vsix') { - const tempDir = process.env.TEST_TEMP ?? path.join(dirname, 'tmp') extensionDevelopmentPath = path.resolve(path.join(tempDir, 'vsix', 'extension')); } @@ -27,6 +26,7 @@ function createCommonOptions(label) { version: vsCodeVersion, env: { MOCHA_VSCODE_TEST: 'true', + TEST_TEMP: tempDir, }, mocha: { ui: 'bdd', @@ -48,7 +48,6 @@ function createCommonOptions(label) { options.extensionDevelopmentPath = extensionDevelopmentPath; } - return options; } diff --git a/package.json b/package.json index 40b1623..5e4c422 100644 --- a/package.json +++ b/package.json @@ -203,7 +203,7 @@ "esbuild": "^0.24.2" }, "mocha-vscode": { - "version": "v1.2.3+d27e65f", - "date": "2024-10-20T15:38:24.261Z" + "version": "v1.2.4+9a52d28", + "date": "2025-02-02T11:56:15.601Z" } -} +} \ No newline at end of file diff --git a/src/configurationFile.ts b/src/configurationFile.ts index 26318c5..e0687b2 100644 --- a/src/configurationFile.ts +++ b/src/configurationFile.ts @@ -14,7 +14,7 @@ import * as path from 'path'; import * as vscode from 'vscode'; import { DisposableStore } from './disposable'; import { HumanError } from './errors'; -import { getPathToNode } from './node'; +import { getPathToNode, isNvmInstalled } from './node'; type OptionsModule = { loadOptions(): IResolvedConfiguration; @@ -41,6 +41,7 @@ export class ConfigurationFile implements vscode.Disposable { private _optionsModule?: OptionsModule; private _configModule?: ConfigModule; private _pathToMocha?: string; + private _pathToNvmRc?: string; /** Cached read promise, invalided on file change. */ private readPromise?: Promise; @@ -140,14 +141,23 @@ export class ConfigurationFile implements vscode.Disposable { async getMochaSpawnArgs(customArgs: readonly string[]): Promise { this._pathToMocha ??= await this._resolveLocalMochaBinPath(); + this._pathToNvmRc ??= await this._resolveNvmRc(); + + let nodeSpawnArgs: string[]; + if ( + this._pathToNvmRc && + (await fs.promises + .access(this._pathToNvmRc) + .then(() => true) + .catch(() => false)) + ) { + nodeSpawnArgs = ['nvm', 'run']; + } else { + this._pathToNvmRc = undefined; + nodeSpawnArgs = [await getPathToNode(this.logChannel)]; + } - return [ - await getPathToNode(this.logChannel), - this._pathToMocha, - '--config', - this.uri.fsPath, - ...customArgs, - ]; + return [...nodeSpawnArgs, this._pathToMocha, '--config', this.uri.fsPath, ...customArgs]; } private getResolver() { @@ -179,6 +189,42 @@ export class ConfigurationFile implements vscode.Disposable { throw new HumanError(`Could not find node_modules above '${mocha}'`); } + private async _resolveNvmRc(): Promise { + // the .nvmrc file can be placed in any location up the directory tree, so we do the same + // starting from the mocha config file + // https://github.com/nvm-sh/nvm/blob/06413631029de32cd9af15b6b7f6ed77743cbd79/nvm.sh#L475-L491 + try { + if (!(await isNvmInstalled())) { + return undefined; + } + + let dir: string | undefined = path.dirname(this.uri.fsPath); + + while (dir) { + const nvmrc = path.join(dir, '.nvmrc'); + if ( + await fs.promises + .access(nvmrc) + .then(() => true) + .catch(() => false) + ) { + this.logChannel.debug(`Found .nvmrc at ${nvmrc}`); + return nvmrc; + } + + const parent = path.dirname(dir); + if (parent === dir) { + break; + } + dir = parent; + } + } catch (e) { + this.logChannel.error(e as Error, 'Error while searching for nvmrc'); + } + + return undefined; + } + private async _resolveLocalMochaBinPath(): Promise { try { const packageJsonPath = await this._resolveLocalMochaPath('/package.json'); @@ -193,17 +239,21 @@ export class ConfigurationFile implements vscode.Disposable { // ignore } - this.logChannel.warn('Could not resolve mocha bin path from package.json, fallback to default'); + this.logChannel.info('Could not resolve mocha bin path from package.json, fallback to default'); return await this._resolveLocalMochaPath('/bin/mocha.js'); } private _resolveLocalMochaPath(suffix: string = ''): Promise { + return this._resolve(`mocha${suffix}`); + } + + private _resolve(request: string): Promise { return new Promise((resolve, reject) => { const dir = path.dirname(this.uri.fsPath); - this.logChannel.debug(`resolving 'mocha${suffix}' via ${dir}`); - this.getResolver().resolve({}, dir, 'mocha' + suffix, {}, (err, res) => { + this.logChannel.debug(`resolving '${request}' via ${dir}`); + this.getResolver().resolve({}, dir, request, {}, (err, res) => { if (err) { - this.logChannel.error(`resolving 'mocha${suffix}' failed with error ${err}`); + this.logChannel.error(`resolving '${request}' failed with error ${err}`); reject( new HumanError( `Could not find mocha in working directory '${path.dirname( @@ -212,7 +262,7 @@ export class ConfigurationFile implements vscode.Disposable { ), ); } else { - this.logChannel.debug(`'mocha${suffix}' resolved to '${res}'`); + this.logChannel.debug(`'${request}' resolved to '${res}'`); resolve(res as string); } }); diff --git a/src/node.ts b/src/node.ts index 7c27258..81c32ca 100644 --- a/src/node.ts +++ b/src/node.ts @@ -7,10 +7,13 @@ * https://opensource.org/licenses/MIT. */ +import fs from 'fs'; +import { homedir } from 'os'; +import path from 'path'; import * as vscode from 'vscode'; import which from 'which'; -export async function getPathTo(logChannel: vscode.LogOutputChannel, bin: string, name: string) { +async function getPathTo(logChannel: vscode.LogOutputChannel, bin: string, name: string) { logChannel.debug(`Resolving ${name} executable`); let pathToBin = await which(bin, { nothrow: true }); if (pathToBin) { @@ -34,11 +37,14 @@ export async function getPathToNode(logChannel: vscode.LogOutputChannel) { return pathToNode; } -let pathToNpm: string | null = null; - -export async function getPathToNpm(logChannel: vscode.LogOutputChannel) { - if (!pathToNpm) { - pathToNpm = await getPathTo(logChannel, 'npm', 'NPM'); +export async function isNvmInstalled() { + // https://github.com/nvm-sh/nvm/blob/179d45050be0a71fd57591b0ed8aedf9b177ba10/install.sh#L27 + const nvmDir = process.env.NVM_DIR || homedir(); + // https://github.com/nvm-sh/nvm/blob/179d45050be0a71fd57591b0ed8aedf9b177ba10/install.sh#L143 + try { + await fs.promises.access(path.join(nvmDir, '.nvm', '.git')); + return true; + } catch (e) { + return false; } - return pathToNpm; } diff --git a/src/test/integration/nvm.test.ts b/src/test/integration/nvm.test.ts new file mode 100644 index 0000000..0757ce5 --- /dev/null +++ b/src/test/integration/nvm.test.ts @@ -0,0 +1,61 @@ +/** + * Copyright (C) Daniel Kuschny (Danielku15) and contributors. + * Copyright (C) Microsoft Corporation. All rights reserved. + * + * Use of this source code is governed by an MIT-style + * license that can be found in the LICENSE file or at + * https://opensource.org/licenses/MIT. + */ + +import { expect } from 'chai'; +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import * as vscode from 'vscode'; +import { isNvmInstalled } from '../../node'; +import { captureTestRun, expectTestTree, getController, integrationTestPrepare } from '../util'; + +describe('nvm', () => { + const workingDir = integrationTestPrepare('nvm'); + + it('discovers tests', async () => { + const c = await getController(); + + expectTestTree(c, [['nvm.test.js', [['nvm', [['ensure-version']]]]]]); + }); + + it('runs tests', async () => { + const c = await getController(); + const profiles = c.profiles; + expect(profiles).to.have.lengthOf(2); + + const run = await captureTestRun( + c, + new vscode.TestRunRequest( + undefined, + undefined, + profiles.find((p) => p.kind === vscode.TestRunProfileKind.Run), + ), + ); + + run.expectStates({ + 'nvm.test.js/nvm/ensure-version': ['enqueued', 'started', 'passed'], + }); + + const expectedVersion = await fs.promises.readFile(path.join(workingDir, '.nvmrc'), 'utf-8'); + const actualVersion = await fs.promises.readFile( + path.resolve(__dirname, '..', '..', '..', 'tmp', '.nvmrc-actual'), + 'utf-8', + ); + + // nvm is only available on MacOS and Linux + // so we skip it on windows. + // also if NVM on local development we skip this test (for GITHUB_ACTIONS we expect it to be there). + const shouldRun = + os.platform() === 'linux' && ((await isNvmInstalled()) || process.env.GITHUB_ACTIONS); + console.log(`Expecting node ${expectedVersion}, ran in ${actualVersion}`); + if (shouldRun) { + expect(process.version).to.match(new RegExp(expectedVersion + '.*')); + } + }); +}); diff --git a/test-workspaces/nvm/.mocharc.js b/test-workspaces/nvm/.mocharc.js new file mode 100644 index 0000000..9df0b71 --- /dev/null +++ b/test-workspaces/nvm/.mocharc.js @@ -0,0 +1,3 @@ +module.exports = { + spec: '**/*.test.js' +}; \ No newline at end of file diff --git a/test-workspaces/nvm/.nvmrc b/test-workspaces/nvm/.nvmrc new file mode 100644 index 0000000..85aee5a --- /dev/null +++ b/test-workspaces/nvm/.nvmrc @@ -0,0 +1 @@ +v20 \ No newline at end of file diff --git a/test-workspaces/nvm/nvm.test.js b/test-workspaces/nvm/nvm.test.js new file mode 100644 index 0000000..a4a0cdf --- /dev/null +++ b/test-workspaces/nvm/nvm.test.js @@ -0,0 +1,9 @@ +const { writeFileSync, mkdirSync } = require('node:fs'); +const { join } = require('node:path'); + +describe('nvm', () => { + it('ensure-version', () => { + mkdirSync(process.env.TEST_TEMP, { recursive: true }); + writeFileSync(join(process.env.TEST_TEMP, '.nvmrc-actual'), process.version); + }); +});