@eyeo/get-browser-binary 0.3.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/browsers.js CHANGED
@@ -16,19 +16,21 @@
16
16
  */
17
17
 
18
18
  import path from "path";
19
- import {exec, execFile} from "child_process";
19
+ import {exec, execFile, spawn} from "child_process";
20
20
  import {promisify} from "util";
21
21
  import fs from "fs";
22
+ import fsExtra from "fs-extra";
22
23
 
23
24
  import got from "got";
24
25
  import webdriver from "selenium-webdriver";
25
26
  import chrome from "selenium-webdriver/chrome.js";
26
27
  import firefox from "selenium-webdriver/firefox.js";
28
+ import edge from "selenium-webdriver/edge.js";
27
29
  import command from "selenium-webdriver/lib/command.js";
28
30
  import extractZip from "extract-zip";
29
31
 
30
- import {download, extractTar, extractDmg, runWinInstaller, getBrowserVersion,
31
- killDriverProcess} from "./utils.js";
32
+ import {download, extractTar, extractDmg, getBrowserVersion, killDriverProcess,
33
+ wait} from "./utils.js";
32
34
 
33
35
  /**
34
36
  * Root folder where browser and webdriver binaries get downloaded.
@@ -37,10 +39,17 @@ import {download, extractTar, extractDmg, runWinInstaller, getBrowserVersion,
37
39
  export let snapshotsBaseDir = path.join(process.cwd(), "browser-snapshots");
38
40
 
39
41
  let {until, By} = webdriver;
40
- const ERROR_DOWNLOAD_NOT_SUPPORTED =
41
- "Downloading this browser is not supported";
42
42
  let platform = `${process.platform}-${process.arch}`;
43
43
 
44
+ function checkVersion(version, minVersion, channels) {
45
+ if (channels.includes(version))
46
+ return;
47
+
48
+ let mainVersion = parseInt(version && version.split(".")[0], 10);
49
+ if (mainVersion < minVersion)
50
+ throw new Error(`Unsupported browser version: ${version}`);
51
+ }
52
+
44
53
  /**
45
54
  * Base class for browser download functionality. Please see subclasses for
46
55
  * browser specific details.
@@ -49,7 +58,9 @@ let platform = `${process.platform}-${process.arch}`;
49
58
  class Browser {
50
59
  /**
51
60
  * @typedef {Object} BrowserBinary
52
- * @property {string} binary - The path to the Chromium binary.
61
+ * @property {string} binary - The path to the browser binary.
62
+ * @property {string} versionNumber - The version number of the browser
63
+ * binary.
53
64
  */
54
65
 
55
66
  /**
@@ -57,9 +68,29 @@ class Browser {
57
68
  * @param {string} version - Either full version number or channel/release.
58
69
  * Please find examples on the subclasses.
59
70
  * @return {BrowserBinary}
71
+ * @throws {Error} Unsupported browser version.
60
72
  */
61
73
  static async downloadBinary(version) {
62
- throw new Error(ERROR_DOWNLOAD_NOT_SUPPORTED);
74
+ // to be implemented by the subclass
75
+ }
76
+
77
+ /**
78
+ * Gets the installed version returned by the browser binary.
79
+ * @param {string} binary - The path to the browser binary.
80
+ * @return {string} Installed browser version.
81
+ */
82
+ static async getInstalledVersion(binary) {
83
+ let stdout;
84
+ if (process.platform == "win32") {
85
+ ({stdout} = await promisify(exec)(
86
+ `(Get-ItemProperty ${binary}).VersionInfo.ProductVersion`,
87
+ {shell: "powershell.exe"})
88
+ );
89
+ }
90
+ else {
91
+ ({stdout} = await promisify(execFile)(binary, ["--version"]));
92
+ }
93
+ return stdout.trim();
63
94
  }
64
95
 
65
96
  /**
@@ -85,11 +116,10 @@ class Browser {
85
116
  * Installs the webdriver matching the browser version and runs the
86
117
  * browser. If needed, the browser binary is also installed.
87
118
  * @param {string} version - Either full version number or channel/release.
88
- * Please find examples on the subclasses. On Edge this parameter has
89
- * no effect.
119
+ * Please find examples on the subclasses.
90
120
  * @param {driverOptions?} options - Options to start the browser with.
91
121
  * @return {webdriver}
92
- * @throws {Error} Unsupported webdriver version.
122
+ * @throws {Error} Unsupported browser version.
93
123
  */
94
124
  static async getDriver(version, options = {}) {
95
125
  // to be implemented by the subclass
@@ -101,6 +131,7 @@ class Browser {
101
131
  * @param {webdriver} driver - The driver controlling the browser.
102
132
  * @param {string} extensionTitle - Title of the extebsion to be enabled.
103
133
  * @return {webdriver}
134
+ * @throws {Error} Unsupported browser version.
104
135
  */
105
136
  static async enableExtensionInIncognito(driver, extensionTitle) {
106
137
  // Allowing the extension in incognito mode can't happen programmatically:
@@ -123,28 +154,30 @@ class Chromium extends Browser {
123
154
  return data.chromium_base_position;
124
155
  }
125
156
 
126
- static async #getLatestVersion(channel = "stable") {
127
- let os = process.platform;
128
- if (os == "win32")
129
- os = process.arch == "x64" ? "win64" : "win";
130
- else if (os == "darwin")
131
- os = process.arch == "arm64" ? "mac_arm64" : "mac";
157
+ static async #getVersionForChannel(channel) {
158
+ if (channel == "latest")
159
+ channel = "stable";
132
160
 
161
+ let os = {
162
+ "win32-ia32": "win",
163
+ "win32-x64": "win64",
164
+ "linux-x64": "linux",
165
+ "darwin-x64": "mac",
166
+ "dawrin-arm64": "mac_arm64"
167
+ }[platform];
133
168
  let data = await got(`https://omahaproxy.appspot.com/all.json?os=${os}`).json();
134
169
  let release = data[0].versions.find(ver => ver.channel == channel);
135
- let {current_version: version, branch_base_position: base} = release;
170
+ let {current_version: version} = release;
136
171
 
137
- if (release.true_branch && release.true_branch.includes("_")) {
172
+ if (release.true_branch && release.true_branch.includes("_"))
138
173
  // A wrong base may be caused by a mini-branch (patched) release
139
174
  // In that case, the base is taken from the unpatched version
140
175
  version = [...version.split(".").slice(0, 3), "0"].join(".");
141
- base = await Chromium.#getBranchBasePosition(version);
142
- }
143
176
 
144
- return {version, base};
177
+ return version;
145
178
  }
146
179
 
147
- static #getChromiumBinary(dir) {
180
+ static #getBinaryPath(dir) {
148
181
  switch (process.platform) {
149
182
  case "win32":
150
183
  return path.join(dir, "chrome-win", "chrome.exe");
@@ -179,7 +212,7 @@ class Chromium extends Browser {
179
212
 
180
213
  try {
181
214
  await fs.promises.access(browserDir);
182
- return {binary: Chromium.#getChromiumBinary(browserDir), revision};
215
+ return {binary: Chromium.#getBinaryPath(browserDir), revision};
183
216
  }
184
217
  catch (e) {}
185
218
 
@@ -208,7 +241,7 @@ class Chromium extends Browser {
208
241
  }
209
242
 
210
243
  await extractZip(archive, {dir: browserDir});
211
- return {binary: Chromium.#getChromiumBinary(browserDir), revision};
244
+ return {binary: Chromium.#getBinaryPath(browserDir), revision};
212
245
  }
213
246
 
214
247
  /**
@@ -217,15 +250,19 @@ class Chromium extends Browser {
217
250
  * number (i.e. "77.0.3865.0"). Defaults to "latest".
218
251
  * @return {BrowserBinary}
219
252
  */
220
- static async downloadBinary(version) {
221
- let base;
222
- if (version && !(version == "beta" || version == "dev"))
223
- base = await Chromium.#getBranchBasePosition(version);
224
- else
225
- ({version, base} = await Chromium.#getLatestVersion(version));
253
+ static async downloadBinary(version = "latest") {
254
+ const MIN_VERSION = 75;
255
+ const CHANNELS = ["latest", "beta", "dev"];
256
+
257
+ checkVersion(version, MIN_VERSION, CHANNELS);
258
+
259
+ let versionNumber = CHANNELS.includes(version) ?
260
+ await Chromium.#getVersionForChannel(version) : version;
261
+
262
+ let base = await Chromium.#getBranchBasePosition(versionNumber);
226
263
 
227
264
  let {binary, revision} = await Chromium.#downloadChromium(base);
228
- return {binary, downloadedVersion: version, revision};
265
+ return {binary, versionNumber, revision};
229
266
  }
230
267
 
231
268
  static async #installDriver(revision, version) {
@@ -245,7 +282,6 @@ class Chromium extends Browser {
245
282
  await download(`https://commondatastorage.googleapis.com/chromium-browser-snapshots/${dir}/${revision}/${zip}`,
246
283
  archive);
247
284
  await extractZip(archive, {dir: cacheDir});
248
- // avoid driver copy failing if the file would be locked
249
285
  await killDriverProcess(Chromium.#DRIVER);
250
286
  await fs.promises.mkdir(destinationDir, {recursive: true});
251
287
  await fs.promises.copyFile(path.join(cacheDir, zip.split(".")[0], driver),
@@ -253,12 +289,13 @@ class Chromium extends Browser {
253
289
  }
254
290
 
255
291
  /** @see Browser.getDriver */
256
- static async getDriver(version, {headless = true, extensionPaths = [],
257
- incognito = false, insecure = false,
258
- extraArgs = []} = {}) {
259
- let {binary, revision, downloadedVersion} =
292
+ static async getDriver(version = "latest", {
293
+ headless = true, extensionPaths = [], incognito = false, insecure = false,
294
+ extraArgs = []
295
+ } = {}) {
296
+ let {binary, revision, versionNumber} =
260
297
  await Chromium.downloadBinary(version);
261
- await Chromium.#installDriver(revision, downloadedVersion);
298
+ await Chromium.#installDriver(revision, versionNumber);
262
299
 
263
300
  let options = new chrome.Options().addArguments("no-sandbox", ...extraArgs);
264
301
  if (extensionPaths.length > 0)
@@ -280,13 +317,8 @@ class Chromium extends Browser {
280
317
 
281
318
  /** @see Browser.enableExtensionInIncognito */
282
319
  static async enableExtensionInIncognito(driver, extensionTitle) {
283
- let version = await getBrowserVersion(driver);
284
- if (version < 75)
285
- // The UI workaround needs a chromedriver >= 75
286
- throw new Error(`Only supported on Chromium >= 75. Current version: ${version}`);
287
-
288
320
  await driver.navigate().to("chrome://extensions");
289
- await driver.executeScript(`
321
+ await driver.executeScript((...args) => {
290
322
  let enable = () => document.querySelector("extensions-manager").shadowRoot
291
323
  .querySelector("extensions-detail-view").shadowRoot
292
324
  .getElementById("allow-incognito").shadowRoot
@@ -299,7 +331,7 @@ class Chromium extends Browser {
299
331
  return new Promise((resolve, reject) => {
300
332
  let extensionDetails;
301
333
  for (let {shadowRoot} of extensions) {
302
- if (shadowRoot.getElementById("name").innerHTML != arguments[0])
334
+ if (shadowRoot.getElementById("name").innerHTML != args[0])
303
335
  continue;
304
336
 
305
337
  extensionDetails = shadowRoot.getElementById("detailsButton");
@@ -310,7 +342,8 @@ class Chromium extends Browser {
310
342
 
311
343
  extensionDetails.click();
312
344
  setTimeout(() => resolve(enable()), 100);
313
- });`, extensionTitle);
345
+ });
346
+ }, extensionTitle);
314
347
  }
315
348
  }
316
349
 
@@ -319,14 +352,14 @@ class Chromium extends Browser {
319
352
  * @hideconstructor
320
353
  * @extends Browser
321
354
  */
322
- class Firefox {
323
- static async #getLatestVersion(branch) {
355
+ class Firefox extends Browser {
356
+ static async #getVersionForChannel(branch) {
324
357
  let data = await got("https://product-details.mozilla.org/1.0/firefox_versions.json").json();
325
358
  return branch == "beta" ?
326
359
  data.LATEST_FIREFOX_DEVEL_VERSION : data.LATEST_FIREFOX_VERSION;
327
360
  }
328
361
 
329
- static #getFirefoxBinary(dir) {
362
+ static #getBinaryPath(dir) {
330
363
  switch (process.platform) {
331
364
  case "win32":
332
365
  return path.join(dir, "core", "firefox.exe");
@@ -342,7 +375,9 @@ class Firefox {
342
375
  static #extractFirefoxArchive(archive, dir) {
343
376
  switch (process.platform) {
344
377
  case "win32":
345
- return runWinInstaller(archive, dir);
378
+ // Procedure inspired from mozinstall:
379
+ // https://hg.mozilla.org/mozilla-central/file/tip/testing/mozbase/mozinstall/mozinstall/mozinstall.py
380
+ return promisify(exec)(`"${archive}" /extractdir=${dir}`);
346
381
  case "linux":
347
382
  return extractTar(archive, dir);
348
383
  case "darwin":
@@ -367,7 +402,7 @@ class Firefox {
367
402
 
368
403
  try {
369
404
  await fs.promises.access(browserDir);
370
- return Firefox.#getFirefoxBinary(browserDir);
405
+ return Firefox.#getBinaryPath(browserDir);
371
406
  }
372
407
  catch (e) {}
373
408
 
@@ -381,7 +416,7 @@ class Firefox {
381
416
  }
382
417
 
383
418
  await Firefox.#extractFirefoxArchive(archive, browserDir);
384
- return Firefox.#getFirefoxBinary(browserDir);
419
+ return Firefox.#getBinaryPath(browserDir);
385
420
  }
386
421
 
387
422
  /**
@@ -390,18 +425,24 @@ class Firefox {
390
425
  * number (i.e. "68.0"). Defaults to "latest".
391
426
  * @return {BrowserBinary}
392
427
  */
393
- static async downloadBinary(version) {
394
- if (!version || version == "beta")
395
- version = await Firefox.#getLatestVersion(version);
428
+ static async downloadBinary(version = "latest") {
429
+ const MIN_VERSION = 60;
430
+ const CHANNELS = ["latest", "beta"];
431
+
432
+ checkVersion(version, MIN_VERSION, CHANNELS);
433
+
434
+ let versionNumber = CHANNELS.includes(version) ?
435
+ await Firefox.#getVersionForChannel(version) : version;
396
436
 
397
- let binary = await Firefox.#downloadFirefox(version);
398
- return {binary};
437
+ let binary = await Firefox.#downloadFirefox(versionNumber);
438
+ return {binary, versionNumber};
399
439
  }
400
440
 
401
441
  /** @see Browser.getDriver */
402
- static async getDriver(version, {headless = true, extensionPaths = [],
403
- incognito = false, insecure = false,
404
- extraArgs = []} = {}) {
442
+ static async getDriver(version = "latest", {
443
+ headless = true, extensionPaths = [], incognito = false, insecure = false,
444
+ extraArgs = []
445
+ } = {}) {
405
446
  let {binary} = await Firefox.downloadBinary(version);
406
447
 
407
448
  let options = new firefox.Options();
@@ -415,10 +456,23 @@ class Firefox {
415
456
  options.addArguments(...extraArgs);
416
457
  options.setBinary(binary);
417
458
 
418
- let driver = await new webdriver.Builder()
419
- .forBrowser("firefox")
420
- .setFirefoxOptions(options)
421
- .build();
459
+ let driver;
460
+ // The OS may be low on resources, that's why building the driver is retried
461
+ // https://github.com/mozilla/geckodriver/issues/1560
462
+ await wait(async() => {
463
+ try {
464
+ driver = await new webdriver.Builder()
465
+ .forBrowser("firefox")
466
+ .setFirefoxOptions(options)
467
+ .build();
468
+ return true;
469
+ }
470
+ catch (err) {
471
+ if (err.message != "Failed to decode response from marionette")
472
+ throw err;
473
+ await killDriverProcess("geckodriver");
474
+ }
475
+ }, 30000, "geckodriver didn't start, likely due to low OS resources", 1000);
422
476
 
423
477
  for (let extensionPath of extensionPaths) {
424
478
  await driver.execute(
@@ -457,32 +511,129 @@ class Firefox {
457
511
  * @hideconstructor
458
512
  * @extends Browser
459
513
  */
460
- class Edge {
514
+ class Edge extends Browser {
461
515
  static #DRIVER = "msedgedriver";
462
516
 
463
- static async #installDriver() {
464
- let stdout;
465
- if (process.platform == "win32") {
466
- let cmd = "(Get-ItemProperty ${Env:ProgramFiles(x86)}\\Microsoft\\" +
467
- "Edge\\Application\\msedge.exe).VersionInfo.ProductVersion";
468
- ({stdout} = await promisify(exec)(cmd, {shell: "powershell.exe"}));
517
+ static async #getVersionForChannel(version) {
518
+ if (!["latest", "beta", "dev"].includes(version))
519
+ return {versionNumber: version, channel: "stable"};
520
+
521
+ let channel = version == "latest" ? "stable" : version;
522
+ let {body} = await got(`https://packages.microsoft.com/repos/edge/pool/main/m/microsoft-edge-${channel}/`);
523
+ let regex = /href="microsoft-edge-(stable|beta|dev)_(.*?)-1_/gm;
524
+ let matches;
525
+ let versionNumbers = [];
526
+ while ((matches = regex.exec(body)) !== null)
527
+ versionNumbers.push(matches[2]);
528
+
529
+ let compareVersions = (v1, v2) =>
530
+ parseInt(v1.split(".")[0], 10) < parseInt(v2.split(".")[0], 10) ? 1 : -1;
531
+ let versionNumber = versionNumbers.sort(compareVersions)[0];
532
+
533
+ return {versionNumber, channel};
534
+ }
535
+
536
+ static #getDarwinAppName(channel) {
537
+ let extra = channel == "stable" ?
538
+ "" : " " + channel.charAt(0).toUpperCase() + channel.slice(1);
539
+ return `Microsoft Edge${extra}`;
540
+ }
541
+
542
+ static #getBinaryPath(channel = "stable") {
543
+ switch (process.platform) {
544
+ case "win32":
545
+ return "${Env:ProgramFiles(x86)}\\Microsoft\\Edge\\Application\\" +
546
+ "msedge.exe";
547
+ case "linux":
548
+ return channel == "stable" ?
549
+ "microsoft-edge" : `microsoft-edge-${channel}`;
550
+ case "darwin":
551
+ let appName = Edge.#getDarwinAppName(channel);
552
+ return `${process.env.HOME}/Applications/${appName}.app/Contents/MacOS/${appName}`;
553
+ default:
554
+ throw new Error(`Unexpected platform: ${process.platform}`);
469
555
  }
470
- else if (process.platform == "darwin") {
471
- ({stdout} = await promisify(execFile)(
472
- "/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge",
473
- ["--version"]
474
- ));
556
+ }
557
+
558
+ /**
559
+ * Downloads the browser binary file.
560
+ * @param {string} version - Either "latest", "beta", "dev" or a full version
561
+ * number (i.e. "95.0.1020.53"). Defaults to "latest". This is only
562
+ * available on Linux.
563
+ * @return {BrowserBinary}
564
+ */
565
+ static async downloadBinary(version = "latest") {
566
+ if (process.platform == "win32")
567
+ // Edge is mandatory on Windows, can't be uninstalled or downgraded
568
+ // https://support.microsoft.com/en-us/microsoft-edge/why-can-t-i-uninstall-microsoft-edge-ee150b3b-7d7a-9984-6d83-eb36683d526d
569
+ throw new Error("Edge download is not supported in Windows");
570
+
571
+ const MIN_VERSION = 95;
572
+ const CHANNELS = ["latest", "beta", "dev"];
573
+ checkVersion(version, MIN_VERSION, CHANNELS);
574
+ let {versionNumber, channel} = await Edge.#getVersionForChannel(version);
575
+
576
+ let darwinName = {
577
+ stable: "MicrosoftEdge",
578
+ beta: "MicrosoftEdgeBeta",
579
+ dev: "MicrosoftEdgeDev"
580
+ }[channel];
581
+ let filename = {
582
+ linux: `microsoft-edge-${channel}_${versionNumber}-1_amd64.deb`,
583
+ darwin: `${darwinName}-${versionNumber}.pkg`
584
+ }[process.platform];
585
+ let darwinArch = process.arch == "arm64" ?
586
+ "03adf619-38c6-4249-95ff-4a01c0ffc962" :
587
+ "C1297A47-86C4-4C1F-97FA-950631F94777";
588
+ let downloadUrl = {
589
+ linux: `https://packages.microsoft.com/repos/edge/pool/main/m/microsoft-edge-${channel}/${filename}`,
590
+ darwin: `https://officecdnmac.microsoft.com/pr/${darwinArch}/MacAutoupdate/${filename}`
591
+ }[process.platform];
592
+
593
+ let snapshotsDir = path.join(snapshotsBaseDir, "edge");
594
+ let archive = path.join(snapshotsDir, "cache", filename);
595
+ let binary = Edge.#getBinaryPath(channel);
596
+ try {
597
+ if (await Edge.#getInstalledVersionNumber(binary) == versionNumber)
598
+ return {binary, versionNumber};
599
+ }
600
+ catch (e) {}
601
+
602
+ try {
603
+ await download(downloadUrl, archive);
604
+ }
605
+ catch (err) {
606
+ throw new Error(`Edge download failed: ${err}`);
475
607
  }
476
- else if (process.platform == "linux") {
477
- ({stdout} = await promisify(execFile)("microsoft-edge", ["--version"]));
608
+
609
+ if (process.platform == "linux") {
610
+ await promisify(exec)(`dpkg -i ${archive}`);
611
+ }
612
+ else if (process.platform == "darwin") {
613
+ let appName = Edge.#getDarwinAppName(channel);
614
+ try {
615
+ await fs.promises.rm(`${process.env.HOME}/Applications/${appName}.app`, {recursive: true});
616
+ }
617
+ catch (e) {}
618
+ await promisify(exec)(`installer -pkg ${archive} -target CurrentUserHomeDirectory`);
478
619
  }
479
620
 
480
- let version = stdout.trim().replace(/.*\s/, "");
481
- if (!version)
621
+ return {binary, versionNumber};
622
+ }
623
+
624
+ static async #getInstalledVersionNumber(binary) {
625
+ let installedVersion = await Edge.getInstalledVersion(binary);
626
+ for (let word of ["beta", "dev", "Beta", "Dev"])
627
+ installedVersion = installedVersion.replace(word, "");
628
+ return installedVersion.trim().replace(/.*\s/, "");
629
+ }
630
+
631
+ static async #installDriver() {
632
+ let binary = Edge.#getBinaryPath();
633
+ let versionNumber = await Edge.#getInstalledVersionNumber(binary);
634
+ if (!versionNumber)
482
635
  throw new Error("Edge is not installed");
483
636
 
484
- // Based on "node_modules/msedgedriver/install.js", adding a fallback
485
- // mechanism when msedgedriver doesn't exist for the latest Edge version.
486
637
  let [zip, driver] = {
487
638
  "win32-ia32": ["edgedriver_win32.zip", "msedgedriver.exe"],
488
639
  "win32-x64": ["edgedriver_win64.zip", "msedgedriver.exe"],
@@ -490,14 +641,11 @@ class Edge {
490
641
  "darwin-x64": ["edgedriver_mac64.zip", "msedgedriver"],
491
642
  "darwin-arm64": ["edgedriver_arm64.zip", "msedgedriver"]
492
643
  }[platform];
493
- let cacheDir = path.join(snapshotsBaseDir, "edge", "cache");
494
- let driverBinDir = path.join(process.cwd(), "node_modules", Edge.#DRIVER,
495
- "bin");
496
- let driverLibDir = path.join(process.cwd(), "node_modules", Edge.#DRIVER,
497
- "lib", Edge.#DRIVER);
498
- let archive = path.join(cacheDir, `${version}-${zip}`);
499
-
500
- let vSplit = version.split(".");
644
+ let cacheDir = path.join(snapshotsBaseDir, "edge", "cache",
645
+ `edgedriver-${versionNumber}`);
646
+ let archive = path.join(cacheDir, `${versionNumber}-${zip}`);
647
+
648
+ let vSplit = versionNumber.split(".");
501
649
  let lastNum = parseInt(vSplit[3], 10);
502
650
  while (lastNum >= 0) {
503
651
  try {
@@ -512,51 +660,50 @@ class Edge {
512
660
  }
513
661
 
514
662
  if (lastNum < 0)
515
- throw new Error(`msedgedriver was not found for Edge ${version}`);
663
+ throw new Error(`msedgedriver was not found for Edge ${versionNumber}`);
516
664
 
517
- await extractZip(archive, {dir: cacheDir});
518
- // avoid driver copy failing if the file would be locked
519
665
  await killDriverProcess(Edge.#DRIVER);
520
- for (let destinationDir of [driverBinDir, driverLibDir]) {
521
- await fs.promises.mkdir(destinationDir, {recursive: true});
522
- await fs.promises.copyFile(path.join(cacheDir, driver),
523
- path.join(destinationDir, driver));
666
+ let driverPath = path.join(cacheDir, driver);
667
+ try {
668
+ await fs.promises.rm(driverPath, {recursive: true});
524
669
  }
670
+ catch (e) {} // file does not exist
671
+ await extractZip(archive, {dir: cacheDir});
672
+
673
+ return driverPath;
525
674
  }
526
675
 
527
676
  /** @see Browser.getDriver */
528
- static async getDriver(version, {headless = true, extensionPaths = [],
529
- incognito = false, insecure = false,
530
- extraArgs = []} = {}) {
531
- await Edge.#installDriver();
677
+ static async getDriver(version = "latest", {
678
+ headless = true, extensionPaths = [], incognito = false, insecure = false,
679
+ extraArgs = []
680
+ } = {}) {
681
+ if (process.platform == "linux" || process.platform == "darwin")
682
+ await Edge.downloadBinary(version);
683
+
684
+ let driverPath = await Edge.#installDriver();
685
+ let serviceBuilder = new edge.ServiceBuilder(driverPath);
532
686
 
533
- let args = ["no-sandbox", ...extraArgs];
687
+ let options = new edge.Options().addArguments("no-sandbox", ...extraArgs);
534
688
  if (headless)
535
- args.push("headless");
689
+ options.headless();
536
690
  if (extensionPaths.length > 0)
537
- args.push(`load-extension=${extensionPaths.join(",")}`);
691
+ options.addArguments(`load-extension=${extensionPaths.join(",")}`);
538
692
  if (incognito)
539
- args.push("incognito");
693
+ options.addArguments("incognito");
694
+ if (insecure)
695
+ options.addArguments("ignore-certificate-errors");
540
696
 
541
697
  let builder = new webdriver.Builder();
542
698
  builder.forBrowser("MicrosoftEdge");
543
- builder.withCapabilities({
544
- "browserName": "MicrosoftEdge",
545
- "ms:edgeChromium": true,
546
- "ms:edgeOptions": {args},
547
- "acceptInsecureCerts": insecure
548
- });
699
+ builder.setEdgeOptions(options);
700
+ builder.setEdgeService(serviceBuilder);
549
701
 
550
702
  return builder.build();
551
703
  }
552
704
 
553
705
  /** @see Browser.enableExtensionInIncognito */
554
706
  static async enableExtensionInIncognito(driver, extensionTitle) {
555
- let version = await getBrowserVersion(driver);
556
- if (version < 79)
557
- // The UI workaround needs a chromium based msedgedriver
558
- throw new Error(`Only supported on Edge >= 79. Current version: ${version}`);
559
-
560
707
  await driver.navigate().to("edge://extensions/");
561
708
  for (let elem of await driver.findElements(By.css("[role=listitem]"))) {
562
709
  let text = await elem.getAttribute("innerHTML");
@@ -577,6 +724,241 @@ class Edge {
577
724
  }
578
725
  }
579
726
 
727
+ /**
728
+ * Download functionality for Opera. This class can be used statically.
729
+ * @hideconstructor
730
+ * @extends Browser
731
+ */
732
+ class Opera extends Browser {
733
+ static async #getVersionForChannel(version, platformDir) {
734
+ let channelPath = "opera/desktop";
735
+ let filePrefix = "Opera";
736
+ if (version != "latest")
737
+ return {versionNumber: version, channelPath, filePrefix};
738
+
739
+ let {body} = await got(`https://ftp.opera.com/pub/${channelPath}`);
740
+ let regex = /href="(\d.*)\/"/gm;
741
+ let matches = body.match(regex);
742
+ let versionNumber;
743
+ while (matches.length > 0) {
744
+ let result = regex.exec(matches.pop());
745
+ if (!result)
746
+ continue;
747
+
748
+ versionNumber = result[1];
749
+ try {
750
+ await got(`https://ftp.opera.com/pub/${channelPath}/${versionNumber}/${platformDir}`);
751
+ break;
752
+ }
753
+ catch (e) {}
754
+ }
755
+
756
+ return {versionNumber, channelPath, filePrefix};
757
+ }
758
+
759
+ static #getBinaryPath(dir) {
760
+ switch (process.platform) {
761
+ case "win32":
762
+ return path.join(dir, "launcher.exe");
763
+ case "linux":
764
+ return path.join("/", "usr", "bin", "opera");
765
+ case "darwin":
766
+ return path.join(dir, "Opera.app", "Contents", "MacOS", "Opera");
767
+ default:
768
+ throw new Error(`Unexpected platform: ${process.platform}`);
769
+ }
770
+ }
771
+
772
+ static async #extractDeb(archive) {
773
+ let child = spawn("dpkg", ["-i", archive]);
774
+
775
+ child.stdout.on("data", data => {
776
+ if (data.toString().includes("Do you want to update Opera")) {
777
+ process.stdin.pipe(child.stdin);
778
+ child.stdin.write("no\r\n");
779
+ }
780
+ });
781
+
782
+ child.stderr.on("data", data => {
783
+ let expectedWarnings = [
784
+ "debconf: unable to initialize frontend",
785
+ "dpkg: warning: downgrading opera-stable",
786
+ "update-alternatives",
787
+ "using /usr/bin/opera",
788
+ "skip creation of",
789
+ "\r\n"
790
+ ];
791
+ if (!expectedWarnings.find(err => data.toString().includes(err.trim())))
792
+ console.error(`stderr: ${data.toString()}`);
793
+ });
794
+
795
+ await new Promise((resolve, reject) => child.on("close", code => {
796
+ if (code != 0)
797
+ reject(`dpkg process exited with code ${code}`);
798
+
799
+ resolve();
800
+ }));
801
+ }
802
+
803
+ static async #installOnWindows(archive, dir, filename) {
804
+ let archiveCopy = path.join(dir, filename);
805
+ await fsExtra.copy(archive, archiveCopy);
806
+ await promisify(exec)(`"${archiveCopy}"`);
807
+ }
808
+
809
+ static #extractOperaArchive(archive, dir, filename) {
810
+ switch (process.platform) {
811
+ case "win32":
812
+ return Opera.#installOnWindows(archive, dir, filename);
813
+ case "linux":
814
+ return Opera.#extractDeb(archive, dir);
815
+ case "darwin":
816
+ return extractTar(archive, dir);
817
+ default:
818
+ throw new Error(`Unexpected platform: ${process.platform}`);
819
+ }
820
+ }
821
+
822
+ /**
823
+ * Downloads the browser binary file.
824
+ * @param {string} version - Either "latest" or a full version number
825
+ * (i.e. "64.0.3417.92"). Defaults to "latest".
826
+ * @return {BrowserBinary}
827
+ */
828
+ static async downloadBinary(version = "latest") {
829
+ const MIN_VERSION = 62;
830
+ const CHANNELS = ["latest"];
831
+
832
+ checkVersion(version, MIN_VERSION, CHANNELS);
833
+
834
+ let [platformDir, fileSuffix] = {
835
+ "win32-ia32": ["win", "Autoupdate.exe"],
836
+ "win32-x64": ["win", "Autoupdate_x64.exe"],
837
+ "linux-x64": ["linux", "amd64.deb"],
838
+ "darwin-x64": ["mac", "Autoupdate.tar.xz"],
839
+ "dawrin-arm64": ["mac", "Autoupdate_arm64.tar.xz"]
840
+ }[platform];
841
+
842
+ let {versionNumber, channelPath, filePrefix} =
843
+ await Opera.#getVersionForChannel(version, platformDir);
844
+
845
+ let snapshotsDir = path.join(snapshotsBaseDir, "opera");
846
+ let browserDir = path.join(snapshotsDir, `opera-${platform}-${versionNumber}`);
847
+ let filename = `${filePrefix}_${versionNumber}_${fileSuffix}`;
848
+ let archive = path.join(snapshotsDir, "cache", filename);
849
+
850
+ let binary = Opera.#getBinaryPath(browserDir);
851
+ try {
852
+ if (process.platform == "linux" &&
853
+ await Opera.getInstalledVersion("opera") == versionNumber)
854
+ return {binary, versionNumber};
855
+
856
+ await fs.promises.access(browserDir);
857
+ return {binary, versionNumber};
858
+ }
859
+ catch (e) {}
860
+
861
+ await fs.promises.mkdir(path.dirname(browserDir), {recursive: true});
862
+ try {
863
+ await fs.promises.access(archive);
864
+ }
865
+ catch (e) {
866
+ let url = `https://ftp.opera.com/pub/${channelPath}/${versionNumber}/${platformDir}/${filename}`;
867
+ try {
868
+ await download(url, archive);
869
+ }
870
+ catch (err) {
871
+ throw new Error(`Browser download unavailable at ${url}\n${err}`);
872
+ }
873
+ }
874
+
875
+ await Opera.#extractOperaArchive(archive, browserDir, filename);
876
+ return {binary, versionNumber};
877
+ }
878
+
879
+ static async #installDriver(version, originalVersion) {
880
+ let [zip, driver] = {
881
+ "win32-ia32": ["operadriver_win32.zip", "operadriver.exe"],
882
+ "win32-x64": ["operadriver_win64.zip", "operadriver.exe"],
883
+ "linux-x64": ["operadriver_linux64.zip", "operadriver"],
884
+ "darwin-x64": ["operadriver_mac64.zip", "operadriver"],
885
+ "darwin-arm64": ["operadriver_mac64.zip", "operadriver"]
886
+ }[platform];
887
+
888
+ let {versionNumber} = await Opera.#getVersionForChannel(version);
889
+ versionNumber = versionNumber.split(".")[0];
890
+
891
+ let cacheDir = path.join(snapshotsBaseDir, "opera", "cache",
892
+ `operadriver-${versionNumber}`);
893
+ let archive = path.join(cacheDir, zip);
894
+
895
+ let {body} = await got(`https://github.com/operasoftware/operachromiumdriver/releases?q=Opera+${versionNumber}&expanded=true`);
896
+ let regex = /release-card[\s\S]*Link--primary.*>(.*)<\/a/gm;
897
+ let matches = body.match(regex);
898
+ if (!matches || matches.length == 0)
899
+ throw new Error(`Driver for Opera ${version} was not found`);
900
+ let driverVersion = regex.exec(matches[matches.length - 1])[1];
901
+
902
+ try {
903
+ await download(`https://github.com/operasoftware/operachromiumdriver/releases/download/v.${driverVersion}/${zip}`,
904
+ archive);
905
+ }
906
+ catch (err) {
907
+ throw new Error(`Downloading operadriver failed: ${err}`);
908
+ }
909
+
910
+ await killDriverProcess("operadriver");
911
+ let driverPath = path.join(cacheDir, zip.split(".")[0], driver);
912
+ try {
913
+ await fs.promises.rm(driverPath, {recursive: true});
914
+ }
915
+ catch (e) {} // file does not exist
916
+ await extractZip(archive, {dir: cacheDir});
917
+ await fs.promises.chmod(driverPath, 577);
918
+
919
+ return driverPath;
920
+ }
921
+
922
+ /** @see Browser.getDriver */
923
+ static async getDriver(version = "latest", {
924
+ headless = true, extensionPaths = [], incognito = false, insecure = false,
925
+ extraArgs = []
926
+ } = {}) {
927
+ let {binary, versionNumber} = await Opera.downloadBinary(version);
928
+ let driverPath = await Opera.#installDriver(versionNumber, version);
929
+ // operadriver uses chrome as a service builder:
930
+ // https://github.com/operasoftware/operachromiumdriver/blob/master/docs/desktop.md
931
+ let serviceBuilder = new chrome.ServiceBuilder(driverPath);
932
+ let service = serviceBuilder.build();
933
+ await service.start();
934
+
935
+ let options = new chrome.Options().addArguments("no-sandbox", ...extraArgs);
936
+ if (extensionPaths.length > 0)
937
+ options.addArguments(`load-extension=${extensionPaths.join(",")}`);
938
+ if (headless)
939
+ options.headless();
940
+ if (insecure)
941
+ options.addArguments("ignore-certificate-errors");
942
+ if (incognito)
943
+ options.addArguments("incognito");
944
+ options.setChromeBinaryPath(binary);
945
+ // https://github.com/operasoftware/operachromiumdriver/issues/61#issuecomment-579331657
946
+ options.addArguments("remote-debugging-port=9222");
947
+
948
+ let builder = new webdriver.Builder();
949
+ builder.forBrowser("chrome");
950
+ builder.setChromeOptions(options);
951
+ builder.setChromeService(serviceBuilder);
952
+
953
+ return builder.build();
954
+ }
955
+
956
+ /** @see Browser.enableExtensionInIncognito */
957
+ static async enableExtensionInIncognito(driver, extensionTitle) {
958
+ // Extensions page in Opera has the same web elements as Chromium
959
+ await Chromium.enableExtensionInIncognito(driver, extensionTitle);
960
+ }
961
+ }
580
962
 
581
963
  /**
582
964
  * @type {Object}
@@ -587,5 +969,6 @@ class Edge {
587
969
  export const BROWSERS = {
588
970
  chromium: Chromium,
589
971
  firefox: Firefox,
590
- edge: Edge
972
+ edge: Edge,
973
+ opera: Opera
591
974
  };