Skip to content

Commit 22d32e5

Browse files
committed
feat: Respect nvmrc for Node version
1 parent 8859e2a commit 22d32e5

File tree

7 files changed

+164
-22
lines changed

7 files changed

+164
-22
lines changed

.github/workflows/build.yml

+17-2
Original file line numberDiff line numberDiff line change
@@ -27,13 +27,28 @@ jobs:
2727
- name: Checkout
2828
uses: actions/checkout@v4
2929

30-
- name: Install Node.js
30+
- name: Install Node.js
31+
if: runner.os != 'Linux'
3132
uses: actions/setup-node@v4
3233
with:
3334
node-version: lts/*
3435

36+
- name: Install Node.js via NVM (Linux)
37+
if: runner.os == 'Linux'
38+
shell: bash
39+
run: |
40+
export NVM_DIR="$HOME/.nvm"
41+
source "$NVM_DIR/nvm.sh"
42+
echo $NVM_DIR >> $GITHUB_PATH
43+
echo "NVM_DIR=$NVM_DIR" >> $GITHUB_ENV
44+
nvm install $(cat test-workspaces/nvm/.nvmrc)
45+
nvm install lts/*
46+
3547
- name: Install dependencies
36-
run: npm install
48+
run: |
49+
node --version
50+
npm --version
51+
npm install
3752
3853
- name: Compile
3954
run: npm run compile:test

src/configurationFile.ts

+63-13
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ import * as path from 'path';
1414
import * as vscode from 'vscode';
1515
import { DisposableStore } from './disposable';
1616
import { HumanError } from './errors';
17-
import { getPathToNode } from './node';
17+
import { getPathToNode, isNvmInstalled } from './node';
1818

1919
type OptionsModule = {
2020
loadOptions(): IResolvedConfiguration;
@@ -41,6 +41,7 @@ export class ConfigurationFile implements vscode.Disposable {
4141
private _optionsModule?: OptionsModule;
4242
private _configModule?: ConfigModule;
4343
private _pathToMocha?: string;
44+
private _pathToNvmRc?: string;
4445

4546
/** Cached read promise, invalided on file change. */
4647
private readPromise?: Promise<ConfigurationList>;
@@ -140,14 +141,23 @@ export class ConfigurationFile implements vscode.Disposable {
140141

141142
async getMochaSpawnArgs(customArgs: readonly string[]): Promise<string[]> {
142143
this._pathToMocha ??= await this._resolveLocalMochaBinPath();
144+
this._pathToNvmRc ??= await this._resolveNvmRc();
145+
146+
let nodeSpawnArgs: string[];
147+
if (
148+
this._pathToNvmRc &&
149+
(await fs.promises
150+
.access(this._pathToNvmRc)
151+
.then(() => true)
152+
.catch(() => false))
153+
) {
154+
nodeSpawnArgs = ['nvm', 'run'];
155+
} else {
156+
this._pathToNvmRc = undefined;
157+
nodeSpawnArgs = [await getPathToNode(this.logChannel)];
158+
}
143159

144-
return [
145-
await getPathToNode(this.logChannel),
146-
this._pathToMocha,
147-
'--config',
148-
this.uri.fsPath,
149-
...customArgs,
150-
];
160+
return [...nodeSpawnArgs, this._pathToMocha, '--config', this.uri.fsPath, ...customArgs];
151161
}
152162

153163
private getResolver() {
@@ -179,6 +189,42 @@ export class ConfigurationFile implements vscode.Disposable {
179189
throw new HumanError(`Could not find node_modules above '${mocha}'`);
180190
}
181191

192+
private async _resolveNvmRc(): Promise<string | undefined> {
193+
// the .nvmrc file can be placed in any location up the directory tree, so we do the same
194+
// starting from the mocha config file
195+
// https://github.com/nvm-sh/nvm/blob/06413631029de32cd9af15b6b7f6ed77743cbd79/nvm.sh#L475-L491
196+
try {
197+
if (!(await isNvmInstalled())) {
198+
return undefined;
199+
}
200+
201+
let dir: string | undefined = path.dirname(this.uri.fsPath);
202+
203+
while (dir) {
204+
const nvmrc = path.join(dir, '.nvmrc');
205+
if (
206+
await fs.promises
207+
.access(nvmrc)
208+
.then(() => true)
209+
.catch(() => false)
210+
) {
211+
this.logChannel.debug(`Found .nvmrc at ${nvmrc}`);
212+
return nvmrc;
213+
}
214+
215+
const parent = path.dirname(dir);
216+
if (parent === dir) {
217+
break;
218+
}
219+
dir = parent;
220+
}
221+
} catch (e) {
222+
this.logChannel.error(e as Error, 'Error while searching for nvmrc');
223+
}
224+
225+
return undefined;
226+
}
227+
182228
private async _resolveLocalMochaBinPath(): Promise<string> {
183229
try {
184230
const packageJsonPath = await this._resolveLocalMochaPath('/package.json');
@@ -193,17 +239,21 @@ export class ConfigurationFile implements vscode.Disposable {
193239
// ignore
194240
}
195241

196-
this.logChannel.warn('Could not resolve mocha bin path from package.json, fallback to default');
242+
this.logChannel.info('Could not resolve mocha bin path from package.json, fallback to default');
197243
return await this._resolveLocalMochaPath('/bin/mocha.js');
198244
}
199245

200246
private _resolveLocalMochaPath(suffix: string = ''): Promise<string> {
247+
return this._resolve(`mocha${suffix}`);
248+
}
249+
250+
private _resolve(request: string): Promise<string> {
201251
return new Promise<string>((resolve, reject) => {
202252
const dir = path.dirname(this.uri.fsPath);
203-
this.logChannel.debug(`resolving 'mocha${suffix}' via ${dir}`);
204-
this.getResolver().resolve({}, dir, 'mocha' + suffix, {}, (err, res) => {
253+
this.logChannel.debug(`resolving '${request}' via ${dir}`);
254+
this.getResolver().resolve({}, dir, request, {}, (err, res) => {
205255
if (err) {
206-
this.logChannel.error(`resolving 'mocha${suffix}' failed with error ${err}`);
256+
this.logChannel.error(`resolving '${request}' failed with error ${err}`);
207257
reject(
208258
new HumanError(
209259
`Could not find mocha in working directory '${path.dirname(
@@ -212,7 +262,7 @@ export class ConfigurationFile implements vscode.Disposable {
212262
),
213263
);
214264
} else {
215-
this.logChannel.debug(`'mocha${suffix}' resolved to '${res}'`);
265+
this.logChannel.debug(`'${request}' resolved to '${res}'`);
216266
resolve(res as string);
217267
}
218268
});

src/node.ts

+13-7
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,13 @@
77
* https://opensource.org/licenses/MIT.
88
*/
99

10+
import fs from 'fs';
11+
import { homedir } from 'os';
12+
import path from 'path';
1013
import * as vscode from 'vscode';
1114
import which from 'which';
1215

13-
export async function getPathTo(logChannel: vscode.LogOutputChannel, bin: string, name: string) {
16+
async function getPathTo(logChannel: vscode.LogOutputChannel, bin: string, name: string) {
1417
logChannel.debug(`Resolving ${name} executable`);
1518
let pathToBin = await which(bin, { nothrow: true });
1619
if (pathToBin) {
@@ -34,11 +37,14 @@ export async function getPathToNode(logChannel: vscode.LogOutputChannel) {
3437
return pathToNode;
3538
}
3639

37-
let pathToNpm: string | null = null;
38-
39-
export async function getPathToNpm(logChannel: vscode.LogOutputChannel) {
40-
if (!pathToNpm) {
41-
pathToNpm = await getPathTo(logChannel, 'npm', 'NPM');
40+
export async function isNvmInstalled() {
41+
// https://github.com/nvm-sh/nvm/blob/179d45050be0a71fd57591b0ed8aedf9b177ba10/install.sh#L27
42+
const nvmDir = process.env.NVM_DIR || homedir();
43+
// https://github.com/nvm-sh/nvm/blob/179d45050be0a71fd57591b0ed8aedf9b177ba10/install.sh#L143
44+
try {
45+
await fs.promises.access(path.join(nvmDir, '.nvm', '.git'));
46+
return true;
47+
} catch (e) {
48+
return false;
4249
}
43-
return pathToNpm;
4450
}

src/test/integration/nvm.test.ts

+41
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
/**
2+
* Copyright (C) Daniel Kuschny (Danielku15) and contributors.
3+
* Copyright (C) Microsoft Corporation. All rights reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style
6+
* license that can be found in the LICENSE file or at
7+
* https://opensource.org/licenses/MIT.
8+
*/
9+
10+
import { expect } from 'chai';
11+
import * as vscode from 'vscode';
12+
import { captureTestRun, expectTestTree, getController, integrationTestPrepare } from '../util';
13+
14+
describe('nvm', () => {
15+
integrationTestPrepare('nvm');
16+
17+
it('discovers tests', async () => {
18+
const c = await getController();
19+
20+
expectTestTree(c, [['nvm.test.js', [['nvm', [['ensure-version']]]]]]);
21+
});
22+
23+
it('runs tests', async () => {
24+
const c = await getController();
25+
const profiles = c.profiles;
26+
expect(profiles).to.have.lengthOf(2);
27+
28+
const run = await captureTestRun(
29+
c,
30+
new vscode.TestRunRequest(
31+
undefined,
32+
undefined,
33+
profiles.find((p) => p.kind === vscode.TestRunProfileKind.Run),
34+
),
35+
);
36+
37+
run.expectStates({
38+
'nvm.test.js/nvm/ensure-version': ['enqueued', 'started', 'passed'],
39+
});
40+
});
41+
});

test-workspaces/nvm/.mocharc.js

+3
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
module.exports = {
2+
spec: '**/*.test.js'
3+
};

test-workspaces/nvm/.nvmrc

+1
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
v20

test-workspaces/nvm/nvm.test.js

+26
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
const { match } = require('node:assert');
2+
const { existsSync } = require('node:fs');
3+
const { platform, homedir } = require('node:os');
4+
const { join } = require('node:path');
5+
6+
describe('nvm', () => {
7+
it('ensure-version', () => {
8+
// keep this in sync with the .nvmrc
9+
const expectedVersion = 'v20';
10+
11+
// nvm is only available on MacOS and Linux
12+
// so we skip it on windows.
13+
// also if NVM on local development we skip this test (for GITHUB_ACTIONS we expect it to be there).
14+
const shouldRun = platform() === 'linux' && (isNvmInstalled() || process.env.GITHUB_ACTIONS);
15+
if (shouldRun) {
16+
match(process.version, new RegExp(expectedVersion + '.*'));
17+
}
18+
});
19+
20+
function isNvmInstalled() {
21+
// https://github.com/nvm-sh/nvm/blob/179d45050be0a71fd57591b0ed8aedf9b177ba10/install.sh#L27
22+
const nvmDir = process.env.NVM_DIR || homedir();
23+
// https://github.com/nvm-sh/nvm/blob/179d45050be0a71fd57591b0ed8aedf9b177ba10/install.sh#L143
24+
return existsSync(join(nvmDir, '.nvm', '.git'));
25+
}
26+
});

0 commit comments

Comments
 (0)