UNPKG

18.9 kBJavaScriptView Raw
1'use strict';
2// @ts-check
3
4const fs = require('node:fs');
5const helper = require('./lib/chromedriver');
6const axios = require('axios').default;
7const path = require('node:path');
8const child_process = require('node:child_process');
9const os = require('node:os');
10const { ProxyAgent } = require('proxy-agent');
11const { promisify } = require('node:util');
12const { finished } = require('node:stream');
13const extractZip = require('extract-zip');
14const { getChromeVersion } = require('@testim/chrome-version');
15const { compareVersions } = require('compare-versions');
16const finishedAsync = promisify(finished);
17const process = require('node:process');
18const console = require('node:console');
19
20class Installer {
21 async install() {
22 const skipDownload = (process.env.npm_config_chromedriver_skip_download || process.env.CHROMEDRIVER_SKIP_DOWNLOAD) === 'true';
23 if (skipDownload) {
24 console.log('Found CHROMEDRIVER_SKIP_DOWNLOAD variable, skipping installation.');
25 process.exit(0);
26 }
27 const cdnUrl = (process.env.npm_config_chromedriver_cdnurl || process.env.CHROMEDRIVER_CDNURL || 'https://googlechromelabs.github.io').replace(/\/+$/, '');
28 const legacyCdnUrl = (process.env.npm_config_chromedriver_legacy_cdnurl || process.env.CHROMEDRIVER_LEGACY_CDNURL || 'https://chromedriver.storage.googleapis.com').replace(/\/+$/, '');
29 let chromedriverVersion = process.env.npm_config_chromedriver_version || process.env.CHROMEDRIVER_VERSION || helper.version;
30 const detectChromedriverVersion = (process.env.npm_config_detect_chromedriver_version || process.env.DETECT_CHROMEDRIVER_VERSION) === 'true';
31 try {
32 if (detectChromedriverVersion) {
33 const includeChromium = (process.env.npm_config_include_chromium || process.env.INCLUDE_CHROMIUM) === 'true';
34 // Refer http://chromedriver.chromium.org/downloads/version-selection
35 const chromeVersion = await getChromeVersion(includeChromium);
36 console.log("Your Chrome version is " + chromeVersion);
37 const versionMatch = /^(.*?)\.\d+$/.exec(chromeVersion);
38 if (versionMatch) {
39 chromedriverVersion = await this.getChromeDriverVersion(cdnUrl, legacyCdnUrl, parseInt(versionMatch[1]));
40 console.log("Compatible ChromeDriver version is " + chromedriverVersion);
41 }
42 } else if (chromedriverVersion === 'LATEST') {
43 chromedriverVersion = await this.getChromeDriverVersion(cdnUrl, legacyCdnUrl);
44 } else {
45 const latestReleaseForVersionMatch = chromedriverVersion.match(/LATEST_(\d+)/);
46 if (latestReleaseForVersionMatch) {
47 chromedriverVersion = await this.getChromeDriverVersion(cdnUrl, legacyCdnUrl, parseInt(latestReleaseForVersionMatch[1]));
48 }
49 }
50 let tmpPath = this.findSuitableTempDirectory(chromedriverVersion);
51 const extractDirectory = tmpPath;
52 const majorVersion = parseInt(chromedriverVersion.split('.')[0]);
53 const useLegacyMethod = majorVersion <= 114;
54 const platform = this.getPlatform(chromedriverVersion);
55 let downloadedFile = this.getDownloadFilePath(useLegacyMethod, tmpPath, platform);
56 if (!useLegacyMethod)
57 tmpPath = path.join(tmpPath, path.basename(downloadedFile, path.extname(downloadedFile)));
58 const chromedriverBinaryFileName = process.platform === 'win32' ? 'chromedriver.exe' : 'chromedriver';
59 const chromedriverBinaryFilePath = path.resolve(tmpPath, chromedriverBinaryFileName);
60 const chromedriverIsAvailable = await this.verifyIfChromedriverIsAvailableAndHasCorrectVersion(chromedriverVersion, chromedriverBinaryFilePath);
61 if (!chromedriverIsAvailable) {
62 console.log('Current existing ChromeDriver binary is unavailable, proceeding with download and extraction.');
63 const configuredfilePath = process.env.npm_config_chromedriver_filepath || process.env.CHROMEDRIVER_FILEPATH;
64 if (configuredfilePath) {
65 console.log('Using file: ', configuredfilePath);
66 downloadedFile = configuredfilePath;
67 } else {
68 if (useLegacyMethod)
69 await this.downloadFileLegacy(legacyCdnUrl, downloadedFile, chromedriverVersion);
70 else
71 await this.downloadFile(cdnUrl, downloadedFile, chromedriverVersion, platform);
72 }
73 await this.extractDownload(extractDirectory, chromedriverBinaryFilePath, downloadedFile);
74 }
75 const libPath = path.join(__dirname, 'lib', 'chromedriver');
76 await this.copyIntoPlace(tmpPath, libPath);
77 this.fixFilePermissions();
78 console.log('Done. ChromeDriver binary available at', helper.path);
79 } catch (err) {
80 console.error('ChromeDriver installation failed', err);
81 process.exit(1);
82 }
83 }
84
85 /**
86 * @param {string} chromedriverVersion
87 */
88 getPlatform(chromedriverVersion) {
89 const thePlatform = process.platform;
90 if (thePlatform === 'linux') {
91 if (process.arch === 'arm64' || process.arch === 's390x' || process.arch === 'x64') {
92 return 'linux64';
93 } else {
94 console.error('Only Linux 64 bits supported.');
95 process.exit(1);
96 }
97 } else if (thePlatform === 'darwin' || thePlatform === 'freebsd') {
98 const osxPlatform = this.getMacOsRealArch(chromedriverVersion);
99
100 if (!osxPlatform) {
101 console.error('Only Mac 64 bits supported.');
102 process.exit(1);
103 }
104
105 return osxPlatform;
106 } else if (thePlatform === 'win32') {
107 if (compareVersions(chromedriverVersion, '115') < 0) {
108 return 'win32';
109 }
110 return (process.arch === 'x64') ? 'win64' : 'win32';
111 }
112 console.error('Unexpected platform or architecture:', process.platform, process.arch);
113 process.exit(1);
114 }
115
116 /**
117 * @param {string} cdnUrl
118 * @param {string} downloadedFile
119 * @param {string} chromedriverVersion
120 * @param {string} platform
121 */
122 async downloadFile(cdnUrl, downloadedFile, chromedriverVersion, platform) {
123 const cdnBinariesUrl = (process.env.npm_config_chromedriver_cdnbinariesurl || process.env.CHROMEDRIVER_CDNBINARIESURL)?.replace(/\/+$/, '');
124 const url = cdnBinariesUrl
125 ? `${cdnBinariesUrl}/${chromedriverVersion}/${platform}/${path.basename(downloadedFile)}`
126 : await this.getDownloadUrl(cdnUrl, chromedriverVersion, platform);
127 if (!url) {
128 console.error(`Download url could not be found for version ${chromedriverVersion} and platform '${platform}'`);
129 process.exit(1);
130 }
131 console.log('Downloading from file: ', url);
132 await this.requestBinary(this.getRequestOptions(url), downloadedFile);
133 }
134
135 /**
136 * @param {string} cdnUrl
137 * @param {string} downloadedFile
138 * @param {string} chromedriverVersion
139 */
140 async downloadFileLegacy(cdnUrl, downloadedFile, chromedriverVersion) {
141 const url = `${cdnUrl}/${chromedriverVersion}/${path.basename(downloadedFile)}`;
142 console.log('Downloading from file: ', url);
143 await this.requestBinary(this.getRequestOptions(url), downloadedFile);
144 }
145
146 /**
147 * @param {any} useLegacyPath
148 * @param {string} dirToLoadTo
149 * @param {string} platform
150 */
151 getDownloadFilePath(useLegacyPath, dirToLoadTo, platform) {
152 const fileName = useLegacyPath ? `chromedriver_${platform}.zip` : `chromedriver-${platform}.zip`;
153 const downloadedFile = path.resolve(dirToLoadTo, fileName);
154 console.log('Saving to file:', downloadedFile);
155 return downloadedFile;
156 }
157
158 /**
159 * @param {string} chromedriverVersion
160 * @param {string} chromedriverBinaryFilePath
161 */
162 verifyIfChromedriverIsAvailableAndHasCorrectVersion(chromedriverVersion, chromedriverBinaryFilePath) {
163 if (!fs.existsSync(chromedriverBinaryFilePath))
164 return Promise.resolve(false);
165 const forceDownload = process.env.npm_config_chromedriver_force_download === 'true' || process.env.CHROMEDRIVER_FORCE_DOWNLOAD === 'true';
166 if (forceDownload)
167 return Promise.resolve(false);
168 console.log('ChromeDriver binary exists. Validating...');
169 const deferred = new Deferred();
170 try {
171 fs.accessSync(chromedriverBinaryFilePath, fs.constants.X_OK);
172 const cp = child_process.spawn(chromedriverBinaryFilePath, ['--version']);
173 let str = '';
174 cp.stdout.on('data', data => str += data);
175 cp.on('error', () => deferred.resolve(false));
176 cp.on('close', code => {
177 if (code !== 0)
178 return deferred.resolve(false);
179 const parts = str.split(' ');
180 if (parts.length < 3)
181 return deferred.resolve(false);
182 if (parts[1].startsWith(chromedriverVersion)) {
183 console.log(`ChromeDriver is already available at '${chromedriverBinaryFilePath}'.`);
184 return deferred.resolve(true);
185 }
186 deferred.resolve(false);
187 });
188 }
189 catch (error) {
190 deferred.resolve(false);
191 }
192 return deferred.promise;
193 }
194
195 /**
196 * @param {string} chromedriverVersion
197 */
198 findSuitableTempDirectory(chromedriverVersion) {
199 const now = Date.now();
200 const candidateTmpDirs = [
201 process.env.npm_config_tmp,
202 process.env.XDG_CACHE_HOME,
203 // Platform specific default, including TMPDIR/TMP/TEMP env
204 os.tmpdir(),
205 path.join(process.cwd(), 'tmp')
206 ];
207
208 for (const tempDir of candidateTmpDirs) {
209 if (!tempDir) continue;
210 const namespace = chromedriverVersion;
211 const candidatePath = path.join(tempDir, namespace, 'chromedriver');
212 try {
213 fs.mkdirSync(candidatePath, { recursive: true });
214 const testFile = path.join(candidatePath, now + '.tmp');
215 fs.writeFileSync(testFile, 'test');
216 fs.unlinkSync(testFile);
217 return candidatePath;
218 } catch (e) {
219 console.log(candidatePath, 'is not writable:', e.message);
220 }
221 }
222 console.error('Can not find a writable tmp directory, please report issue on https://github.com/giggio/chromedriver/issues/ with as much information as possible.');
223 process.exit(1);
224 }
225
226 getRequestOptions(downloadPath) {
227 /** @type import('axios').AxiosRequestConfig */
228 const options = { url: downloadPath, method: "GET" };
229 const urlParts = new URL(downloadPath);
230 const isHttps = urlParts.protocol === 'https:';
231
232 if (isHttps) {
233 // Use certificate authority settings from npm
234 let ca = process.env.npm_config_ca;
235 if (ca)
236 console.log('Using npmconf ca.');
237
238 if (!ca && process.env.npm_config_cafile) {
239 try {
240 ca = fs.readFileSync(process.env.npm_config_cafile, { encoding: 'utf8' });
241 } catch (e) {
242 console.error('Could not read cafile', process.env.npm_config_cafile, e);
243 }
244 console.log('Using npmconf cafile.');
245 }
246 options.httpsAgent = new ProxyAgent({
247 rejectUnauthorized: !!process.env.npm_config_strict_ssl,
248 ca: ca
249 });
250 options.proxy = false;
251 } else {
252 const { getProxyForUrl } = require("proxy-from-env");
253 const proxyUrl = getProxyForUrl(downloadPath);
254 if (proxyUrl) {
255 const proxyUrlParts = new URL(proxyUrl);
256 if (proxyUrlParts.hostname && proxyUrlParts.protocol)
257 options.proxy = {
258 host: proxyUrlParts.hostname,
259 port: proxyUrlParts.port ? parseInt(proxyUrlParts.port) : 80,
260 protocol: proxyUrlParts.protocol
261 };
262 }
263 }
264
265 // Use specific User-Agent
266 if (process.env.npm_config_user_agent) {
267 options.headers = { 'User-Agent': process.env.npm_config_user_agent };
268 }
269
270 return options;
271 }
272
273 /**
274 * @param {string} cdnUrl
275 * @param {string} legacyCdnUrl
276 * @param {number} [majorVersion]
277 * @returns {Promise<string>}
278 */
279 async getChromeDriverVersion(cdnUrl, legacyCdnUrl, majorVersion) {
280 if (majorVersion == null || majorVersion > 114) {
281 console.log('Finding Chromedriver version.');
282 let chromedriverVersion;
283 if (majorVersion) {
284 const requestOptions = this.getRequestOptions(`${cdnUrl}/chrome-for-testing/latest-versions-per-milestone.json`);
285 const response = await axios.request(requestOptions);
286 chromedriverVersion = response.data?.milestones[majorVersion.toString()]?.version;
287 } else {
288 const requestOptions = this.getRequestOptions(`${cdnUrl}/chrome-for-testing/last-known-good-versions.json`);
289 const response = await axios.request(requestOptions);
290 chromedriverVersion = response.data?.channels?.Stable?.version;
291 }
292 console.log(`Chromedriver version is ${chromedriverVersion}.`);
293 return chromedriverVersion;
294 } else {
295 console.log('Finding Chromedriver version using legacy method.');
296 const urlPath = majorVersion ? `LATEST_RELEASE_${majorVersion}` : 'LATEST_RELEASE';
297 const requestOptions = this.getRequestOptions(`${legacyCdnUrl}/${urlPath}`);
298 const response = await axios.request(requestOptions);
299 const chromedriverVersion = response.data.trim();
300 console.log(`Chromedriver version is ${chromedriverVersion}.`);
301 return chromedriverVersion;
302 }
303 }
304
305 /**
306 * @param {string} cdnUrl
307 * @param {string} version
308 * @param {string} platform
309 * @returns {Promise<[string]>}
310 */
311 async getDownloadUrl(cdnUrl, version, platform) {
312 const getUrlUrl = `${cdnUrl}/chrome-for-testing/${version}.json`;
313 const requestOptions = this.getRequestOptions(getUrlUrl);
314 const response = await axios.request(requestOptions);
315 const url = response.data?.downloads?.chromedriver?.find((/** @type {{ platform: string; }} */ c) => c.platform == platform)?.url;
316 return url;
317 }
318
319 /**
320 *
321 * @param {import('axios').AxiosRequestConfig} requestOptions
322 * @param {string} filePath
323 */
324 async requestBinary(requestOptions, filePath) {
325 const outFile = fs.createWriteStream(filePath);
326 let response;
327 try {
328 response = await axios.request({ responseType: 'stream', ...requestOptions });
329 } catch (error) {
330 let errorData = '';
331 if (error && error.response) {
332 if (error.response.status)
333 console.error('Error status code:', error.response.status);
334 if (error.response.data) {
335 error.response.data.on('data', data => errorData += data.toString('utf8'));
336 try {
337 await finishedAsync(error.response.data);
338 } catch (error) {
339 console.error('Error downloading entire response:', error);
340 }
341 }
342 }
343 console.error(`Error with http(s) request:\n${error}\nError data:\n${errorData}`);
344 process.exit(1);
345 }
346 let count = 0;
347 let notifiedCount = 0;
348 response.data.on('data', data => {
349 count += data.length;
350 if ((count - notifiedCount) > 1024 * 1024) {
351 console.log('Received ' + Math.floor(count / 1024) + 'K...');
352 notifiedCount = count;
353 }
354 });
355 response.data.on('end', () => console.log('Received ' + Math.floor(count / 1024) + 'K total.'));
356 const pipe = response.data.pipe(outFile);
357 await new Promise((resolve, reject) => {
358 pipe.on('finish', resolve);
359 pipe.on('error', reject);
360 });
361 }
362
363 /**
364 * @param {string} dirToExtractTo
365 * @param {string} chromedriverBinaryFilePath
366 * @param {string} downloadedFile
367 */
368 async extractDownload(dirToExtractTo, chromedriverBinaryFilePath, downloadedFile) {
369 if (path.extname(downloadedFile) !== '.zip') {
370 fs.mkdirSync(path.dirname(chromedriverBinaryFilePath), { recursive: true });
371 fs.copyFileSync(downloadedFile, chromedriverBinaryFilePath);
372 console.log('Skipping zip extraction - binary file found.');
373 return;
374 }
375 console.log(`Extracting zip contents to ${dirToExtractTo}.`);
376 try {
377 await extractZip(path.resolve(downloadedFile), { dir: dirToExtractTo });
378 } catch (error) {
379 console.error('Error extracting archive: ' + error);
380 process.exit(1);
381 }
382 }
383
384 /**
385 * @param {string} originPath
386 * @param {string} targetPath
387 */
388 async copyIntoPlace(originPath, targetPath) {
389 fs.rmSync(targetPath, { recursive: true, force: true });
390 console.log(`Copying from ${originPath} to target path ${targetPath}`);
391 fs.mkdirSync(targetPath);
392
393 // Look for the extracted directory, so we can rename it.
394 const files = fs.readdirSync(originPath, { withFileTypes: true })
395 .filter(dirent => dirent.isFile() && dirent.name.startsWith('chromedriver') && !dirent.name.endsWith(".debug") && !dirent.name.endsWith(".zip"))
396 .map(dirent => dirent.name);
397 const promises = files.map(name => {
398 return /** @type {Promise<void>} */(new Promise((resolve) => {
399 const file = path.join(originPath, name);
400 const reader = fs.createReadStream(file);
401 const targetFile = path.join(targetPath, name);
402 const writer = fs.createWriteStream(targetFile);
403 writer.on("close", () => resolve());
404 reader.pipe(writer);
405 }));
406 });
407 await Promise.all(promises);
408 }
409
410
411 fixFilePermissions() {
412 // Check that the binary is user-executable and fix it if it isn't (problems with unzip library)
413 if (process.platform != 'win32') {
414 const stat = fs.statSync(helper.path);
415 // 64 == 0100 (no octal literal in strict mode)
416 if (!(stat.mode & 64)) {
417 console.log('Fixing file permissions.');
418 fs.chmodSync(helper.path, '755');
419 }
420 }
421 }
422
423 /**
424 * @param {string} chromedriverVersion
425 */
426 getMacOsRealArch(chromedriverVersion) {
427 if (process.arch === 'arm64' || this.isEmulatedRosettaEnvironment()) {
428 return compareVersions(chromedriverVersion, '106.0.5249.61') < 0
429 ? 'mac64_m1'
430 : compareVersions(chromedriverVersion, '115') < 0 ? 'mac_arm64' : 'mac-arm64';
431 }
432
433 if (process.arch === 'x64') {
434 return compareVersions(chromedriverVersion, '115') < 0 ? 'mac64' : 'mac-x64';
435 }
436
437 return null;
438 }
439
440 isEmulatedRosettaEnvironment() {
441 const archName = child_process.spawnSync('uname', ['-m']).stdout.toString().trim();
442
443 if (archName === 'x86_64') {
444 const proc = child_process.spawnSync('sysctl', ['-in', 'sysctl.proc_translated']);
445
446 // When run with `-in`, the return code is 0 even if there is no `sysctl.proc_translated`
447 if (proc.status) {
448 console.error('Unexpected return code from sysctl: ' + proc.status);
449 process.exit(1);
450 }
451
452 // If there is no `sysctl.proc_translated` (i.e. not rosetta) then nothing is printed to
453 // stdout
454 if (!proc.stdout) {
455 return false;
456 }
457
458 const processTranslated = proc.stdout.toString().trim();
459
460 return processTranslated === '1';
461 }
462
463 return false;
464 }
465
466}
467
468function Deferred() {
469 this.resolve = null;
470 this.reject = null;
471 this.promise = new Promise(function (resolve, reject) {
472 this.resolve = resolve;
473 this.reject = reject;
474 }.bind(this));
475 Object.freeze(this);
476}
477
478if (require.main === module)
479 new Installer().install();
480else if (process.env.NODE_ENV === 'test')
481 module.exports = Installer;