Skip to content

Commit f41749c

Browse files
author
Danny McCormick
authored
Detect correct node handler (microsoft#503)
* Allow testing with handlers other than node 10 * Fix imports * Update based on discussion * Fix up tests * Get rid of bad logging * Add doc explaining change * Add types * nit: unused import * Respond to feedback * Respond to feedback * Bump major version to 3.0.0-preview * Update package-lock.json * Use parameters instead of env variables * Optional parameter * Feedback * Use chdir * Remove shelljs dep * add back shelljs dep * Add marker file * Clean files before downloading * Update mock-test.ts * React to strict null checking * Clean directory before installing node
1 parent 12db892 commit f41749c

File tree

7 files changed

+295
-48
lines changed

7 files changed

+295
-48
lines changed

node/docs/nodeVersioning.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
# Node versioning
2+
3+
## Agent Node Handler
4+
5+
The agent currently has 2 different node handlers that it can use to execute node tasks: Node 6 and Node 10.
6+
The handler used depends on the `execution` property specified in the tasks `task.json`.
7+
If the `execution` property is specified to be `Node`, the task will run on the Node 6 handler, if it is specified to be `Node10` it will run on the Node 10 handler.
8+
9+
## Mock-test Node Handler
10+
11+
[Unit testing](https://docs.microsoft.com/en-us/azure/devops/extend/develop/add-build-task?view=azure-devops#step-2-unit-testing-your-task-scripts) of tasks can be done using the task-lib's built in mock-task functionality.
12+
To ensure tests are run in the same environment as the agent, this library looks for a `task.json` file in the same directory as the supplied task entry point.
13+
If no `task.json` is found it searches all ancestor directories as well.
14+
If the `task.json` is still not found, the library defaults to node 10, otherwise it uses the appropriate handler based on the `execution` property.
15+
If this version of node is not found on the path, the library downloads the appropriate version.
16+
17+
### Behavior overrides
18+
19+
To specify a specific version of node to use, set the `nodeVersion` optional parameter in the `run` function of the `MockTestRunner` to the integer major version (e.g. `mtr.run(5)`).
20+
To specify the location of a `task.json` file, set the `taskJsonPath` optional parameter in the `MockTestRunner` constructor to the path of the file (e.g. `let mtr = new mt.MockTaskRunner('<pathToTest>', '<pathToTask.json>'`).

node/make.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ target.test = function() {
4646
buildutils.getExternals();
4747
run('tsc -p ./test');
4848
cp('-Rf', rp('test/scripts'), testPath);
49+
cp('-Rf', rp('test/fakeTasks'), testPath);
4950
process.env['TASKLIB_INPROC_UNITS'] = '1'; // export task-lib internals for internal unit testing
5051
run('mocha ' + testPath);
5152
}

node/mock-test.ts

Lines changed: 196 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,28 @@
11
import cp = require('child_process');
22
import fs = require('fs');
3-
import path = require('path');
3+
import ncp = require('child_process');
44
import os = require('os');
5+
import path = require('path');
56
import cmdm = require('./taskcommand');
67
import shelljs = require('shelljs');
8+
import syncRequest = require('sync-request');
79

810
const COMMAND_TAG = '[command]';
911
const COMMAND_LENGTH = COMMAND_TAG.length;
12+
const downloadDirectory = path.join(process.env.HOME || process.env.HOMEPATH || process.env.USERPROFILE, 'azure-pipelines-task-lib', '_download');
1013

1114
export class MockTestRunner {
12-
constructor(private _testPath: string) {
15+
constructor(testPath: string, taskJsonPath?: string) {
16+
this._taskJsonPath = taskJsonPath || '';
17+
this._testPath = testPath;
18+
this.nodePath = this.getNodePath();
1319
}
1420

21+
private _testPath = '';
22+
private _taskJsonPath = '';
23+
public nodePath = '';
1524
public stdout = '';
16-
public stderr = ''
25+
public stderr = '';
1726
public cmdlines = {};
1827
public invokedToolCount = 0;
1928
public succeeded = false;
@@ -44,22 +53,18 @@ export class MockTestRunner {
4453
return this.stderr.indexOf(message) > 0;
4554
}
4655

47-
public run(): void {
56+
public run(nodeVersion?: number): void {
4857
this.cmdlines = {};
4958
this.invokedToolCount = 0;
5059
this.succeeded = true;
5160

5261
this.errorIssues = [];
5362
this.warningIssues = [];
5463

55-
// we use node in the path.
56-
// if you want to test with a specific node, ensure it's in the path
57-
let nodePath = shelljs.which('node');
58-
if (!nodePath) {
59-
console.error('Could not find node in path');
60-
return;
64+
let nodePath = this.nodePath;
65+
if (nodeVersion) {
66+
nodePath = this.getNodePath(nodeVersion);
6167
}
62-
6368
let spawn = cp.spawnSync(nodePath, [this._testPath]);
6469
if (spawn.error) {
6570
console.error('Running test failed');
@@ -123,4 +128,184 @@ export class MockTestRunner {
123128
console.log('TRACE FILE: ' + traceFile);
124129
}
125130
}
131+
132+
// Returns a path to node.exe with the correct version for this task (based on if its node10 or node)
133+
private getNodePath(nodeVersion?: number): string {
134+
const version: number = nodeVersion || this.getNodeVersion();
135+
136+
let downloadVersion: string;
137+
switch (version) {
138+
case 5:
139+
downloadVersion = 'v5.10.1';
140+
break;
141+
case 6:
142+
downloadVersion = 'v6.10.3';
143+
break;
144+
case 10:
145+
downloadVersion = 'v10.15.1';
146+
break;
147+
default:
148+
throw new Error('Invalid node version, must be 5, 6, or 10 (received ' + version + ')');
149+
}
150+
151+
// Install node in home directory if it isn't already there.
152+
const downloadDestination: string = path.join(downloadDirectory, 'node' + version);
153+
const pathToExe: string = this.getPathToNodeExe(downloadVersion, downloadDestination);
154+
if (pathToExe) {
155+
return pathToExe;
156+
}
157+
else {
158+
return this.downloadNode(downloadVersion, downloadDestination);
159+
}
160+
}
161+
162+
// Determines the correct version of node to use based on the contents of the task's task.json. Defaults to Node 10.
163+
private getNodeVersion(): number {
164+
const taskJsonPath: string = this.getTaskJsonPath();
165+
if (!taskJsonPath) {
166+
console.warn('Unable to find task.json, defaulting to use Node 10');
167+
return 10;
168+
}
169+
const taskJsonContents = fs.readFileSync(taskJsonPath, { encoding: 'utf-8' });
170+
const taskJson: object = JSON.parse(taskJsonContents);
171+
172+
let nodeVersionFound = false;
173+
const execution: object = taskJson['execution'];
174+
const keys = Object.keys(execution);
175+
for (let i = 0; i < keys.length; i++) {
176+
if (keys[i].toLowerCase() == 'node10') {
177+
// Prefer node 10 and return immediately.
178+
return 10;
179+
}
180+
else if (keys[i].toLowerCase() == 'node') {
181+
nodeVersionFound = true;
182+
}
183+
}
184+
185+
if (!nodeVersionFound) {
186+
console.warn('Unable to determine execution type from task.json, defaulting to use Node 10');
187+
return 10;
188+
}
189+
190+
return 6;
191+
}
192+
193+
// Returns the path to the task.json for the task being tested. Returns null if unable to find it.
194+
// Searches by moving up the directory structure from the initial starting point and checking at each level.
195+
private getTaskJsonPath(): string {
196+
if (this._taskJsonPath) {
197+
return this._taskJsonPath;
198+
}
199+
let curPath: string = this._testPath;
200+
let newPath: string = path.join(this._testPath, '..');
201+
while (curPath != newPath) {
202+
curPath = newPath;
203+
let taskJsonPath: string = path.join(curPath, 'task.json');
204+
if (fs.existsSync(taskJsonPath)) {
205+
return taskJsonPath;
206+
}
207+
newPath = path.join(curPath, '..');
208+
}
209+
return '';
210+
}
211+
212+
// Downloads the specified node version to the download destination. Returns a path to node.exe
213+
private downloadNode(nodeVersion: string, downloadDestination: string): string {
214+
shelljs.rm('-rf', downloadDestination);
215+
const nodeUrl: string = 'https://nodejs.org/dist';
216+
let downloadPath = '';
217+
switch (this.getPlatform()) {
218+
case 'darwin':
219+
this.downloadTarGz(nodeUrl + '/' + nodeVersion + '/node-' + nodeVersion + '-darwin-x64.tar.gz', downloadDestination);
220+
downloadPath = path.join(downloadDestination, 'node-' + nodeVersion + '-darwin-x64', 'bin', 'node');
221+
break;
222+
case 'linux':
223+
this.downloadTarGz(nodeUrl + '/' + nodeVersion + '/node-' + nodeVersion + '-linux-x64.tar.gz', downloadDestination);
224+
downloadPath = path.join(downloadDestination, 'node-' + nodeVersion + '-linux-x64', 'bin', 'node');
225+
break;
226+
case 'win32':
227+
this.downloadFile(nodeUrl + '/' + nodeVersion + '/win-x64/node.exe', downloadDestination, 'node.exe');
228+
this.downloadFile(nodeUrl + '/' + nodeVersion + '/win-x64/node.lib', downloadDestination, 'node.lib');
229+
downloadPath = path.join(downloadDestination, 'node.exe')
230+
}
231+
232+
// Write marker to indicate download completed.
233+
const marker = downloadDestination + '.completed';
234+
fs.writeFileSync(marker, '');
235+
236+
return downloadPath;
237+
}
238+
239+
// Downloads file to the downloadDestination, making any necessary folders along the way.
240+
private downloadFile(url: string, downloadDestination: string, fileName: string): void {
241+
const filePath: string = path.join(downloadDestination, fileName);
242+
if (!url) {
243+
throw new Error('Parameter "url" must be set.');
244+
}
245+
if (!downloadDestination) {
246+
throw new Error('Parameter "downloadDestination" must be set.');
247+
}
248+
console.log('Downloading file:', url);
249+
shelljs.mkdir('-p', downloadDestination);
250+
const result: any = syncRequest('GET', url);
251+
fs.writeFileSync(filePath, result.getBody());
252+
}
253+
254+
// Downloads tarGz to the download destination, making any necessary folders along the way.
255+
private downloadTarGz(url: string, downloadDestination: string): void {
256+
if (!url) {
257+
throw new Error('Parameter "url" must be set.');
258+
}
259+
if (!downloadDestination) {
260+
throw new Error('Parameter "downloadDestination" must be set.');
261+
}
262+
const tarGzName: string = 'node.tar.gz';
263+
this.downloadFile(url, downloadDestination, tarGzName);
264+
265+
// Extract file
266+
const originalCwd: string = process.cwd();
267+
process.chdir(downloadDestination);
268+
try {
269+
ncp.execSync(`tar -xzf "${path.join(downloadDestination, tarGzName)}"`);
270+
}
271+
catch {
272+
throw new Error('Failed to unzip node tar.gz from ' + url);
273+
}
274+
finally {
275+
process.chdir(originalCwd);
276+
}
277+
}
278+
279+
// Checks if node is installed at downloadDestination. If it is, returns a path to node.exe, otherwise returns null.
280+
private getPathToNodeExe(nodeVersion: string, downloadDestination: string): string {
281+
let exePath = '';
282+
switch (this.getPlatform()) {
283+
case 'darwin':
284+
exePath = path.join(downloadDestination, 'node-' + nodeVersion + '-darwin-x64', 'bin', 'node');
285+
break;
286+
case 'linux':
287+
exePath = path.join(downloadDestination, 'node-' + nodeVersion + '-linux-x64', 'bin', 'node');
288+
break;
289+
case 'win32':
290+
exePath = path.join(downloadDestination, 'node.exe');
291+
}
292+
293+
// Only use path if marker is found indicating download completed successfully (and not partially)
294+
const marker = downloadDestination + '.completed';
295+
296+
if (fs.existsSync(exePath) && fs.existsSync(marker)) {
297+
return exePath;
298+
}
299+
else {
300+
return '';
301+
}
302+
}
303+
304+
private getPlatform(): string {
305+
let platform: string = os.platform();
306+
if (platform != 'darwin' && platform != 'linux' && platform != 'win32') {
307+
throw new Error('Unexpected platform: ' + platform);
308+
}
309+
return platform;
310+
}
126311
}

0 commit comments

Comments
 (0)