Skip to content

Commit 86c1557

Browse files
fix: pacote does not respect CLI's proxy configuration
Pacote does not respect the proxy settings set with `tns proxy set ...`. Pass the required options and add unit tests for the pacote service (use sinon instead of mocking with injector for the `pacote` and `tar` packages as the service is our wrapper for pacote).
1 parent 7fe76e5 commit 86c1557

File tree

5 files changed

+332
-17
lines changed

5 files changed

+332
-17
lines changed

lib/common

lib/definitions/pacote-service.d.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ declare global {
1818
extractPackage(packageName: string, destinationDirectory: string, options?: IPacoteExtractOptions): Promise<void>;
1919
}
2020

21-
interface IPacoteBaseOptions {
21+
interface IPacoteBaseOptions extends IProxySettingsBase {
2222
/**
2323
* The path to npm cache
2424
*/

lib/services/pacote-service.ts

+45-15
Original file line numberDiff line numberDiff line change
@@ -4,48 +4,78 @@ import * as path from "path";
44

55
export class PacoteService implements IPacoteService {
66
constructor(private $fs: IFileSystem,
7-
private $npm: INodePackageManager) { }
7+
private $npm: INodePackageManager,
8+
private $proxyService: IProxyService,
9+
private $logger: ILogger) { }
810

911
public async manifest(packageName: string, options?: IPacoteManifestOptions): Promise<any> {
10-
// In case `tns create myapp --template https://github.com/NativeScript/template-hello-world.git` command is executed, pacote module throws an error if cache option is not provided.
11-
const cache = await this.$npm.getCachePath();
12-
const manifestOptions = { cache };
12+
this.$logger.trace(`Calling pacoteService.manifest for packageName: '${packageName}' and options: ${options}`);
13+
const manifestOptions: IPacoteBaseOptions = await this.getPacoteBaseOptions();
1314
if (options) {
1415
_.extend(manifestOptions, options);
1516
}
1617

17-
if (this.$fs.exists(packageName)) {
18-
packageName = path.resolve(packageName);
19-
}
20-
18+
packageName = this.getRealPackageName(packageName);
19+
this.$logger.trace(`Calling pacote.manifest for packageName: ${packageName} and options: ${JSON.stringify(manifestOptions, null, 2)}`);
2120
return pacote.manifest(packageName, manifestOptions);
2221
}
2322

2423
public async extractPackage(packageName: string, destinationDirectory: string, options?: IPacoteExtractOptions): Promise<void> {
2524
// strip: Remove the specified number of leading path elements. Pathnames with fewer elements will be silently skipped. More info: https://github.com/npm/node-tar/blob/e89c4d37519b1c20133a9f49d5f6b85fa34c203b/README.md
2625
// C: Create an archive
26+
this.$logger.trace(`Calling pacoteService.extractPackage for packageName: '${packageName}', destinationDir: '${destinationDirectory}' and options: ${options}`);
2727
const extractOptions = { strip: 1, C: destinationDirectory };
2828
if (options) {
2929
_.extend(extractOptions, options);
3030
}
3131

32-
if (this.$fs.exists(packageName)) {
33-
packageName = path.resolve(packageName);
34-
}
32+
packageName = this.getRealPackageName(packageName);
33+
const pacoteOptions = await this.getPacoteBaseOptions();
3534

36-
const cache = await this.$npm.getCachePath();
3735
return new Promise<void>((resolve, reject) => {
38-
const source = pacote.tarball.stream(packageName, { cache });
36+
this.$logger.trace(`Calling pacoteService.extractPackage for packageName: '${packageName}', destinationDir: '${destinationDirectory}' and options: ${options}`);
37+
38+
const source = pacote.tarball.stream(packageName, pacoteOptions);
3939
source.on("error", (err: Error) => {
40+
this.$logger.trace(`Error in source while trying to extract stream from ${packageName}. Error is ${err}`);
4041
reject(err);
4142
});
4243

44+
this.$logger.trace(`Creating extract tar stream with options: ${JSON.stringify(extractOptions, null, 2)}`);
4345
const destination = tar.x(extractOptions);
4446
source.pipe(destination);
4547

46-
destination.on("error", (err: Error) => reject(err));
47-
destination.on("finish", () => resolve());
48+
destination.on("error", (err: Error) => {
49+
this.$logger.trace(`Error in destination while trying to extract stream from ${packageName}. Error is ${err}`);
50+
reject(err);
51+
});
52+
53+
destination.on("finish", () => {
54+
this.$logger.trace(`Successfully extracted '${packageName}' to ${destinationDirectory}`);
55+
resolve();
56+
});
4857
});
4958
}
59+
60+
private async getPacoteBaseOptions(): Promise<IPacoteBaseOptions> {
61+
// In case `tns create myapp --template https://github.com/NativeScript/template-hello-world.git` command is executed, pacote module throws an error if cache option is not provided.
62+
const cache = await this.$npm.getCachePath();
63+
const pacoteOptions = { cache };
64+
const proxySettings = await this.$proxyService.getCache();
65+
if (proxySettings) {
66+
_.extend(pacoteOptions, proxySettings);
67+
}
68+
69+
return pacoteOptions;
70+
}
71+
72+
private getRealPackageName(packageName: string): string {
73+
if (this.$fs.exists(packageName)) {
74+
this.$logger.trace(`Will resolve the full path to package ${packageName}.`);
75+
packageName = path.resolve(packageName);
76+
}
77+
78+
return packageName;
79+
}
5080
}
5181
$injector.register("pacoteService", PacoteService);

test/project-service.ts

+3
Original file line numberDiff line numberDiff line change
@@ -152,6 +152,9 @@ class ProjectIntegrationTest {
152152
executeAfterHooks: async (commandName: string, hookArguments?: IDictionary<any>): Promise<void> => undefined
153153
});
154154
this.testInjector.register("pacoteService", PacoteService);
155+
this.testInjector.register("proxyService", {
156+
getCache: async (): Promise<IProxySettings> => null
157+
});
155158
}
156159
}
157160

test/services/pacote-service.ts

+282
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,282 @@
1+
import { Yok } from "../../lib/common/yok";
2+
import { assert } from "chai";
3+
import { PacoteService } from '../../lib/services/pacote-service';
4+
import { LoggerStub } from "../stubs";
5+
import { sandbox, SinonSandbox, SinonStub } from "sinon";
6+
import { EventEmitter } from "events";
7+
const pacote = require("pacote");
8+
const tar = require("tar");
9+
const path = require("path");
10+
11+
const npmCachePath = "npmCachePath";
12+
const packageName = "testPackage";
13+
const fullPath = `/Users/username/${packageName}`;
14+
const destinationDir = "destinationDir";
15+
const defaultPacoteOpts: IPacoteBaseOptions = { cache: npmCachePath };
16+
const errorMessage = "error message";
17+
const proxySettings: IProxySettings = {
18+
hostname: "hostname",
19+
proxy: "proxy",
20+
port: "8888",
21+
rejectUnauthorized: true,
22+
username: null,
23+
password: null
24+
};
25+
26+
interface ITestSetup {
27+
isLocalPackage?: boolean;
28+
useProxySettings?: boolean;
29+
npmGetCachePathError?: Error;
30+
}
31+
32+
interface ITestCase extends ITestSetup {
33+
manifestOptions?: IPacoteManifestOptions;
34+
additionalExtractOpts?: IPacoteExtractOptions;
35+
name: string;
36+
expectedArgs: any[];
37+
}
38+
39+
const createTestInjector = (opts?: ITestSetup): IInjector => {
40+
opts = opts || {};
41+
42+
const testInjector = new Yok();
43+
testInjector.register("logger", LoggerStub);
44+
testInjector.register("pacoteService", PacoteService);
45+
testInjector.register("fs", {
46+
exists: (p: string): boolean => opts.isLocalPackage
47+
});
48+
49+
testInjector.register("proxyService", {
50+
getCache: async (): Promise<IProxySettings> => opts.useProxySettings ? proxySettings : null
51+
});
52+
53+
testInjector.register("npm", {
54+
getCachePath: async (): Promise<string> => {
55+
if (opts.npmGetCachePathError) {
56+
throw opts.npmGetCachePathError;
57+
}
58+
59+
return npmCachePath;
60+
}
61+
});
62+
63+
return testInjector;
64+
};
65+
66+
class MockStream extends EventEmitter {
67+
public pipe(destination: any, options?: { end?: boolean; }): any {
68+
// Nothing to do here, just mock the method.
69+
}
70+
}
71+
72+
describe("pacoteService", () => {
73+
const manifestResult: any = {};
74+
const manifestOptions: IPacoteManifestOptions = { fullMetadata: true };
75+
let sandboxInstance: SinonSandbox = null;
76+
let manifestStub: SinonStub = null;
77+
let tarballStreamStub: SinonStub = null;
78+
let tarXStub: SinonStub = null;
79+
let tarballSourceStream: MockStream = null;
80+
let tarExtractDestinationStream: MockStream = null;
81+
82+
beforeEach(() => {
83+
sandboxInstance = sandbox.create();
84+
manifestStub = sandboxInstance.stub(pacote, "manifest").returns(Promise.resolve(manifestResult));
85+
tarballSourceStream = new MockStream();
86+
tarballStreamStub = sandboxInstance.stub(pacote.tarball, "stream").returns(tarballSourceStream);
87+
tarExtractDestinationStream = new MockStream();
88+
tarXStub = sandboxInstance.stub(tar, "x").returns(tarExtractDestinationStream);
89+
});
90+
91+
afterEach(() => {
92+
sandboxInstance.restore();
93+
});
94+
95+
const setupTest = (opts?: ITestSetup): IPacoteService => {
96+
opts = opts || {};
97+
const testInjector = createTestInjector(opts);
98+
if (opts.isLocalPackage) {
99+
sandboxInstance.stub(path, "resolve").withArgs(packageName).returns(fullPath);
100+
}
101+
102+
return testInjector.resolve<IPacoteService>("pacoteService");
103+
};
104+
105+
describe("manifest", () => {
106+
describe("calls pacote.manifest", () => {
107+
108+
const testData: ITestCase[] = [
109+
{
110+
name: "with 'cache' only when no opts are passed",
111+
expectedArgs: [packageName, defaultPacoteOpts]
112+
},
113+
{
114+
name: "with 'cache' and passed options",
115+
manifestOptions,
116+
expectedArgs: [packageName, _.extend({}, defaultPacoteOpts, manifestOptions)]
117+
},
118+
{
119+
name: "with 'cache' and proxy settings",
120+
useProxySettings: true,
121+
expectedArgs: [packageName, _.extend({}, defaultPacoteOpts, proxySettings)]
122+
},
123+
{
124+
name: "with 'cache', passed options and proxy settings when proxy is configured",
125+
manifestOptions,
126+
useProxySettings: true,
127+
expectedArgs: [packageName, _.extend({}, defaultPacoteOpts, manifestOptions, proxySettings)]
128+
},
129+
{
130+
name: "with full path to file when it is local one",
131+
isLocalPackage: true,
132+
expectedArgs: [fullPath, defaultPacoteOpts]
133+
},
134+
{
135+
name: "with full path to file, 'cache' and passed options when local path is passed",
136+
manifestOptions,
137+
isLocalPackage: true,
138+
expectedArgs: [fullPath, _.extend({}, defaultPacoteOpts, manifestOptions)]
139+
},
140+
{
141+
name: "with full path to file, 'cache' and proxy settings when proxy is configured",
142+
manifestOptions,
143+
isLocalPackage: true,
144+
useProxySettings: true,
145+
expectedArgs: [fullPath, _.extend({}, defaultPacoteOpts, manifestOptions, proxySettings)]
146+
},
147+
{
148+
name: "with full path to file, 'cache', passed options and proxy settings when proxy is configured and local path is passed",
149+
manifestOptions,
150+
useProxySettings: true,
151+
isLocalPackage: true,
152+
expectedArgs: [fullPath, _.extend({}, defaultPacoteOpts, manifestOptions, proxySettings)]
153+
},
154+
];
155+
156+
testData.forEach(testCase => {
157+
it(testCase.name, async () => {
158+
const pacoteService = setupTest(testCase);
159+
const result = await pacoteService.manifest(packageName, testCase.manifestOptions);
160+
161+
assert.equal(result, manifestResult);
162+
assert.deepEqual(manifestStub.firstCall.args, testCase.expectedArgs);
163+
});
164+
});
165+
});
166+
167+
it("fails with npm error when unable to get npm cache", async () => {
168+
const npmGetCachePathError = new Error("npm error");
169+
const pacoteService = setupTest({ npmGetCachePathError });
170+
await assert.isRejected(pacoteService.manifest(packageName, null), npmGetCachePathError.message);
171+
});
172+
});
173+
174+
describe("extractPackage", () => {
175+
it("fails with correct error when pacote.tarball.stream raises error event", async () => {
176+
const pacoteService = setupTest();
177+
178+
const pacoteExtractPackagePromise = pacoteService.extractPackage(packageName, destinationDir);
179+
setImmediate(() => {
180+
tarballSourceStream.emit("error", new Error(errorMessage));
181+
});
182+
183+
await assert.isRejected(pacoteExtractPackagePromise, errorMessage);
184+
});
185+
186+
it("fails with correct error when the destination stream raises error event", async () => {
187+
const pacoteService = setupTest();
188+
189+
const pacoteExtractPackagePromise = pacoteService.extractPackage(packageName, destinationDir);
190+
setImmediate(() => {
191+
tarExtractDestinationStream.emit("error", new Error(errorMessage));
192+
});
193+
194+
await assert.isRejected(pacoteExtractPackagePromise, errorMessage);
195+
});
196+
197+
it("resolves when the destination stream emits finish event", async () => {
198+
const pacoteService = setupTest();
199+
200+
const pacoteExtractPackagePromise = pacoteService.extractPackage(packageName, destinationDir);
201+
setImmediate(() => {
202+
tarExtractDestinationStream.emit("finish");
203+
});
204+
205+
await assert.isFulfilled(pacoteExtractPackagePromise);
206+
});
207+
208+
describe("passes correct options to tar.x", () => {
209+
const defaultExtractOpts = { strip: 1, C: destinationDir };
210+
const additionalExtractOpts: IPacoteExtractOptions = {
211+
filter: (p: string, stat: any) => true
212+
};
213+
214+
const testData: ITestCase[] = [
215+
{
216+
name: "when only default options should be passed",
217+
expectedArgs: [defaultExtractOpts],
218+
},
219+
{
220+
name: "when additional options are passed",
221+
expectedArgs: [_.extend({}, defaultExtractOpts, additionalExtractOpts)],
222+
additionalExtractOpts
223+
},
224+
];
225+
226+
testData.forEach(testCase => {
227+
it(testCase.name, async () => {
228+
const pacoteService = setupTest();
229+
230+
const pacoteExtractPackagePromise = pacoteService.extractPackage(packageName, destinationDir, testCase.additionalExtractOpts);
231+
setImmediate(() => {
232+
tarExtractDestinationStream.emit("finish");
233+
});
234+
235+
await assert.isFulfilled(pacoteExtractPackagePromise);
236+
237+
assert.deepEqual(tarXStub.firstCall.args, testCase.expectedArgs);
238+
});
239+
});
240+
});
241+
242+
describe("passes correct options to pacote.tarball.stream", () => {
243+
const testData: ITestCase[] = [
244+
{
245+
name: "when proxy is not set",
246+
expectedArgs: [packageName, defaultPacoteOpts]
247+
},
248+
{
249+
name: "when proxy is not set and a local path is passed",
250+
isLocalPackage: true,
251+
expectedArgs: [fullPath, defaultPacoteOpts]
252+
},
253+
{
254+
name: "when proxy is set",
255+
useProxySettings: true,
256+
expectedArgs: [packageName, _.extend({}, defaultPacoteOpts, proxySettings)]
257+
},
258+
{
259+
name: "when proxy is set and a local path is passed",
260+
useProxySettings: true,
261+
isLocalPackage: true,
262+
expectedArgs: [fullPath, _.extend({}, defaultPacoteOpts, proxySettings)]
263+
},
264+
265+
];
266+
267+
testData.forEach(testCase => {
268+
it(testCase.name, async () => {
269+
const pacoteService = setupTest(testCase);
270+
271+
const pacoteExtractPackagePromise = pacoteService.extractPackage(packageName, destinationDir);
272+
setImmediate(() => {
273+
tarExtractDestinationStream.emit("finish");
274+
});
275+
276+
await assert.isFulfilled(pacoteExtractPackagePromise);
277+
assert.deepEqual(tarballStreamStub.firstCall.args, testCase.expectedArgs);
278+
});
279+
});
280+
});
281+
});
282+
});

0 commit comments

Comments
 (0)