@eyeo/get-browser-binary 0.2.0 → 0.4.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,9 +16,10 @@
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";
@@ -27,8 +28,8 @@ import firefox from "selenium-webdriver/firefox.js";
27
28
  import command from "selenium-webdriver/lib/command.js";
28
29
  import extractZip from "extract-zip";
29
30
 
30
- import {download, extractTar, extractDmg, runWinInstaller, getBrowserVersion}
31
- from "./utils.js";
31
+ import {download, extractTar, extractDmg, getBrowserVersion, killDriverProcess,
32
+ wait} from "./utils.js";
32
33
 
33
34
  /**
34
35
  * Root folder where browser and webdriver binaries get downloaded.
@@ -37,9 +38,9 @@ import {download, extractTar, extractDmg, runWinInstaller, getBrowserVersion}
37
38
  export let snapshotsBaseDir = path.join(process.cwd(), "browser-snapshots");
38
39
 
39
40
  let {until, By} = webdriver;
40
- const ERROR_INCOGNITO_NOT_SUPPORTED = "Incognito mode is not supported";
41
41
  const ERROR_DOWNLOAD_NOT_SUPPORTED =
42
42
  "Downloading this browser is not supported";
43
+ let platform = `${process.platform}-${process.arch}`;
43
44
 
44
45
  /**
45
46
  * Base class for browser download functionality. Please see subclasses for
@@ -49,7 +50,9 @@ const ERROR_DOWNLOAD_NOT_SUPPORTED =
49
50
  class Browser {
50
51
  /**
51
52
  * @typedef {Object} BrowserBinary
52
- * @property {string} binary - The path to the Chromium binary.
53
+ * @property {string} binary - The path to the browser binary.
54
+ * @property {string} versionNumber - The version number of the browser
55
+ * binary.
53
56
  */
54
57
 
55
58
  /**
@@ -62,6 +65,25 @@ class Browser {
62
65
  throw new Error(ERROR_DOWNLOAD_NOT_SUPPORTED);
63
66
  }
64
67
 
68
+ /**
69
+ * Gets the installed version returned by the browser binary.
70
+ * @param {string} binary - The path to the browser binary.
71
+ * @return {string} Installed browser version.
72
+ */
73
+ static async getInstalledVersion(binary) {
74
+ let stdout;
75
+ if (process.platform == "win32") {
76
+ ({stdout} = await promisify(exec)(
77
+ `(Get-ItemProperty ${binary}).VersionInfo.ProductVersion`,
78
+ {shell: "powershell.exe"})
79
+ );
80
+ }
81
+ else {
82
+ ({stdout} = await promisify(execFile)(binary, ["--version"]));
83
+ }
84
+ return stdout;
85
+ }
86
+
65
87
  /**
66
88
  * @typedef {Object} webdriver
67
89
  * @see https://www.selenium.dev/selenium/docs/api/javascript/index.html
@@ -89,6 +111,7 @@ class Browser {
89
111
  * no effect.
90
112
  * @param {driverOptions?} options - Options to start the browser with.
91
113
  * @return {webdriver}
114
+ * @throws {Error} Unsupported webdriver version.
92
115
  */
93
116
  static async getDriver(version, options = {}) {
94
117
  // to be implemented by the subclass
@@ -102,7 +125,10 @@ class Browser {
102
125
  * @return {webdriver}
103
126
  */
104
127
  static async enableExtensionInIncognito(driver, extensionTitle) {
105
- throw new Error(ERROR_INCOGNITO_NOT_SUPPORTED);
128
+ // Allowing the extension in incognito mode can't happen programmatically:
129
+ // https://stackoverflow.com/questions/57419654
130
+ // https://bugzilla.mozilla.org/show_bug.cgi?id=1729315
131
+ // That is done through the UI, to be implemented by the subclass
106
132
  }
107
133
  }
108
134
 
@@ -159,17 +185,13 @@ class Chromium extends Browser {
159
185
 
160
186
  let revision = parseInt(chromiumRevision, 10);
161
187
  let startingRevision = revision;
162
- let platform = `${process.platform}-${process.arch}`;
163
-
164
- let buildTypes = {
188
+ let [platformDir, fileName] = {
165
189
  "win32-ia32": ["Win", "chrome-win.zip"],
166
190
  "win32-x64": ["Win_x64", "chrome-win.zip"],
167
191
  "linux-x64": ["Linux_x64", "chrome-linux.zip"],
168
192
  "darwin-x64": ["Mac", "chrome-mac.zip"],
169
193
  "dawrin-arm64": ["Mac_Arm", "chrome-mac.zip"]
170
- };
171
-
172
- let [platformDir, fileName] = buildTypes[platform];
194
+ }[platform];
173
195
  let archive = null;
174
196
  let browserDir = null;
175
197
  let snapshotsDir = path.join(snapshotsBaseDir, "chromium");
@@ -225,19 +247,17 @@ class Chromium extends Browser {
225
247
  ({version, base} = await Chromium.#getLatestVersion(version));
226
248
 
227
249
  let {binary, revision} = await Chromium.#downloadChromium(base);
228
- return {binary, downloadedVersion: version, revision};
250
+ return {binary, versionNumber: version, revision};
229
251
  }
230
252
 
231
253
  static async #installDriver(revision, version) {
232
- let platform = `${process.platform}-${process.arch}`;
233
- let buildTypes = {
254
+ let [dir, zip, driver] = {
234
255
  "win32-ia32": ["Win", "chromedriver_win32.zip", "chromedriver.exe"],
235
256
  "win32-x64": ["Win_x64", "chromedriver_win32.zip", "chromedriver.exe"],
236
257
  "linux-x64": ["Linux_x64", "chromedriver_linux64.zip", "chromedriver"],
237
258
  "darwin-x64": ["Mac", "chromedriver_mac64.zip", "chromedriver"],
238
259
  "darwin-arm64": ["Mac_Arm", "chromedriver_mac64.zip", "chromedriver"]
239
- };
240
- let [dir, zip, driver] = buildTypes[platform];
260
+ }[platform];
241
261
 
242
262
  let cacheDir = path.join(snapshotsBaseDir, "chromium", "cache", version);
243
263
  let destinationDir = path.join(process.cwd(), "node_modules",
@@ -247,28 +267,19 @@ class Chromium extends Browser {
247
267
  await download(`https://commondatastorage.googleapis.com/chromium-browser-snapshots/${dir}/${revision}/${zip}`,
248
268
  archive);
249
269
  await extractZip(archive, {dir: cacheDir});
270
+ await killDriverProcess(Chromium.#DRIVER);
250
271
  await fs.promises.mkdir(destinationDir, {recursive: true});
251
- try {
252
- await fs.promises.copyFile(path.join(cacheDir, zip.split(".")[0], driver),
253
- path.join(destinationDir, driver));
254
- }
255
- catch (err) {
256
- // https://gitlab.com/eyeo/developer-experience/get-browser-binary/-/issues/3
257
- if (!err.toString().includes("copyfile"))
258
- throw err;
259
- }
272
+ await fs.promises.copyFile(path.join(cacheDir, zip.split(".")[0], driver),
273
+ path.join(destinationDir, driver));
260
274
  }
261
275
 
262
276
  /** @see Browser.getDriver */
263
277
  static async getDriver(version, {headless = true, extensionPaths = [],
264
278
  incognito = false, insecure = false,
265
279
  extraArgs = []} = {}) {
266
- if (incognito)
267
- throw new Error(ERROR_INCOGNITO_NOT_SUPPORTED);
268
-
269
- let {binary, revision, downloadedVersion} =
280
+ let {binary, revision, versionNumber} =
270
281
  await Chromium.downloadBinary(version);
271
- await Chromium.#installDriver(revision, downloadedVersion);
282
+ await Chromium.#installDriver(revision, versionNumber);
272
283
 
273
284
  let options = new chrome.Options().addArguments("no-sandbox", ...extraArgs);
274
285
  if (extensionPaths.length > 0)
@@ -277,6 +288,8 @@ class Chromium extends Browser {
277
288
  options.headless();
278
289
  if (insecure)
279
290
  options.addArguments("ignore-certificate-errors");
291
+ if (incognito)
292
+ options.addArguments("incognito");
280
293
  options.setChromeBinaryPath(binary);
281
294
 
282
295
  let builder = new webdriver.Builder();
@@ -285,6 +298,42 @@ class Chromium extends Browser {
285
298
 
286
299
  return builder.build();
287
300
  }
301
+
302
+ /** @see Browser.enableExtensionInIncognito */
303
+ static async enableExtensionInIncognito(driver, extensionTitle) {
304
+ let version = await getBrowserVersion(driver);
305
+ // Webdriver capabilities don't include a browser version for Opera
306
+ if (version && version < 75)
307
+ // The UI workaround needs a chromedriver >= 75
308
+ throw new Error(`Only supported on Chromium >= 75. Current version: ${version}`);
309
+
310
+ await driver.navigate().to("chrome://extensions");
311
+ await driver.executeScript(`
312
+ let enable = () => document.querySelector("extensions-manager").shadowRoot
313
+ .querySelector("extensions-detail-view").shadowRoot
314
+ .getElementById("allow-incognito").shadowRoot
315
+ .getElementById("crToggle").click();
316
+
317
+ let extensions = document.querySelector("extensions-manager").shadowRoot
318
+ .getElementById("items-list").shadowRoot
319
+ .querySelectorAll("extensions-item");
320
+
321
+ return new Promise((resolve, reject) => {
322
+ let extensionDetails;
323
+ for (let {shadowRoot} of extensions) {
324
+ if (shadowRoot.getElementById("name").innerHTML != arguments[0])
325
+ continue;
326
+
327
+ extensionDetails = shadowRoot.getElementById("detailsButton");
328
+ }
329
+
330
+ if (!extensionDetails)
331
+ reject("Extension was not found");
332
+
333
+ extensionDetails.click();
334
+ setTimeout(() => resolve(enable()), 100);
335
+ });`, extensionTitle);
336
+ }
288
337
  }
289
338
 
290
339
  /**
@@ -292,7 +341,7 @@ class Chromium extends Browser {
292
341
  * @hideconstructor
293
342
  * @extends Browser
294
343
  */
295
- class Firefox {
344
+ class Firefox extends Browser {
296
345
  static async #getLatestVersion(branch) {
297
346
  let data = await got("https://product-details.mozilla.org/1.0/firefox_versions.json").json();
298
347
  return branch == "beta" ?
@@ -315,7 +364,9 @@ class Firefox {
315
364
  static #extractFirefoxArchive(archive, dir) {
316
365
  switch (process.platform) {
317
366
  case "win32":
318
- return runWinInstaller(archive, dir);
367
+ // Procedure inspired from mozinstall:
368
+ // https://hg.mozilla.org/mozilla-central/file/tip/testing/mozbase/mozinstall/mozinstall/mozinstall.py
369
+ return promisify(exec)(`"${archive}" /extractdir=${dir}`);
319
370
  case "linux":
320
371
  return extractTar(archive, dir);
321
372
  case "darwin":
@@ -326,18 +377,18 @@ class Firefox {
326
377
  }
327
378
 
328
379
  static async #downloadFirefox(version) {
329
- let {platform} = process;
330
- if (platform == "win32")
331
- platform += "-" + process.arch;
332
- let buildTypes = {
380
+ let [buildPlatform, fileName] = {
333
381
  "win32-ia32": ["win32", `Firefox Setup ${version}.exe`],
334
382
  "win32-x64": ["win64", `Firefox Setup ${version}.exe`],
335
- "linux": ["linux-x86_64", `firefox-${version}.tar.bz2`],
336
- "darwin": ["mac", `Firefox ${version}.dmg`]
337
- };
383
+ "linux-x64": ["linux-x86_64", `firefox-${version}.tar.bz2`],
384
+ "darwin-x64": ["mac", `Firefox ${version}.dmg`],
385
+ "darwin-arm64": ["mac", `Firefox ${version}.dmg`]
386
+ }[platform];
338
387
 
339
388
  let snapshotsDir = path.join(snapshotsBaseDir, "firefox");
340
389
  let browserDir = path.join(snapshotsDir, `firefox-${platform}-${version}`);
390
+ let archive = path.join(snapshotsDir, "cache", fileName);
391
+
341
392
  try {
342
393
  await fs.promises.access(browserDir);
343
394
  return Firefox.#getFirefoxBinary(browserDir);
@@ -345,10 +396,6 @@ class Firefox {
345
396
  catch (e) {}
346
397
 
347
398
  await fs.promises.mkdir(path.dirname(browserDir), {recursive: true});
348
-
349
- let [buildPlatform, fileName] = buildTypes[platform];
350
- let archive = path.join(snapshotsDir, "cache", fileName);
351
-
352
399
  try {
353
400
  await fs.promises.access(archive);
354
401
  }
@@ -372,7 +419,7 @@ class Firefox {
372
419
  version = await Firefox.#getLatestVersion(version);
373
420
 
374
421
  let binary = await Firefox.#downloadFirefox(version);
375
- return {binary};
422
+ return {binary, versionNumber: version};
376
423
  }
377
424
 
378
425
  /** @see Browser.getDriver */
@@ -392,10 +439,23 @@ class Firefox {
392
439
  options.addArguments(...extraArgs);
393
440
  options.setBinary(binary);
394
441
 
395
- let driver = await new webdriver.Builder()
396
- .forBrowser("firefox")
397
- .setFirefoxOptions(options)
398
- .build();
442
+ let driver;
443
+ // The OS may be low on resources, that's why building the driver is retried
444
+ // https://github.com/mozilla/geckodriver/issues/1560
445
+ await wait(async() => {
446
+ try {
447
+ driver = await new webdriver.Builder()
448
+ .forBrowser("firefox")
449
+ .setFirefoxOptions(options)
450
+ .build();
451
+ return true;
452
+ }
453
+ catch (err) {
454
+ if (err.message != "Failed to decode response from marionette")
455
+ throw err;
456
+ await killDriverProcess("geckodriver");
457
+ }
458
+ }, 30000, "geckodriver didn't start, likely due to low OS resources", 1000);
399
459
 
400
460
  for (let extensionPath of extensionPaths) {
401
461
  await driver.execute(
@@ -409,14 +469,10 @@ class Firefox {
409
469
 
410
470
  /** @see Browser.enableExtensionInIncognito */
411
471
  static async enableExtensionInIncognito(driver, extensionTitle) {
412
- // Allowing the extension in private browsing can't happen programmatically:
413
- // https://bugzilla.mozilla.org/show_bug.cgi?id=1729315
414
- // Therefore, that is done through the UI
415
472
  let version = await getBrowserVersion(driver);
416
- if (version < 87) {
473
+ if (version < 87)
417
474
  // The UI workaround assumes web elements only present on Firefox >= 87
418
475
  throw new Error(`Only supported on Firefox >= 87. Current version: ${version}`);
419
- }
420
476
 
421
477
  await driver.navigate().to("about:addons");
422
478
  await driver.wait(until.elementLocated(By.name("extension")), 1000).click();
@@ -438,41 +494,94 @@ class Firefox {
438
494
  * @hideconstructor
439
495
  * @extends Browser
440
496
  */
441
- class Edge {
497
+ class Edge extends Browser {
442
498
  static #DRIVER = "msedgedriver";
443
499
 
444
- static async #installDriver() {
445
- let stdout;
446
- if (process.platform == "win32") {
447
- let cmd = "(Get-ItemProperty ${Env:ProgramFiles(x86)}\\Microsoft\\" +
448
- "Edge\\Application\\msedge.exe).VersionInfo.ProductVersion";
449
- ({stdout} = await promisify(exec)(cmd, {shell: "powershell.exe"}));
500
+ static async #getVersionForChannel(version) {
501
+ if (!["latest", "beta", "dev"].includes(version))
502
+ return {versionNumber: version, channel: "stable"};
503
+
504
+ let channel = version == "latest" ? "stable" : version;
505
+ let {body} = await got(`https://packages.microsoft.com/repos/edge/pool/main/m/microsoft-edge-${channel}`);
506
+ let regex = /href="microsoft-edge-(stable|beta|dev)_(.*?)-1_/gm;
507
+ let matches;
508
+ let versionNumbers = [];
509
+ while ((matches = regex.exec(body)) !== null)
510
+ versionNumbers.push(matches[2]);
511
+
512
+ let compareVersions = (v1, v2) =>
513
+ parseInt(v1.split(".")[0], 10) < parseInt(v2.split(".")[0], 10) ? 1 : -1;
514
+ let versionNumber = versionNumbers.sort(compareVersions)[0];
515
+
516
+ return {versionNumber, channel};
517
+ }
518
+
519
+ /**
520
+ * Downloads the browser binary file.
521
+ * @param {string} version - Either "latest", "beta", "dev" or a full version
522
+ * number (i.e. "95.0.1020.53"). Defaults to "latest". This is only
523
+ * available on Linux.
524
+ * @return {BrowserBinary}
525
+ */
526
+ static async downloadBinary(version = "latest") {
527
+ if (process.platform != "linux")
528
+ throw new Error("Edge download is only supported on Linux");
529
+
530
+ let {versionNumber, channel} = await Edge.#getVersionForChannel(version);
531
+
532
+ let snapshotsDir = path.join(snapshotsBaseDir, "edge");
533
+ let filename = `microsoft-edge-${channel}_${versionNumber}-1_amd64.deb`;
534
+ let archive = path.join(snapshotsDir, "cache", filename);
535
+ let binary = {
536
+ stable: "microsoft-edge",
537
+ beta: "microsoft-edge-beta",
538
+ dev: "microsoft-edge-dev"
539
+ }[channel];
540
+
541
+ try {
542
+ if (await Edge.#getInstalledVersionNumber(binary) == versionNumber)
543
+ return {binary, versionNumber};
450
544
  }
451
- else if (process.platform == "darwin") {
452
- ({stdout} = await promisify(execFile)(
453
- "/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge",
454
- ["--version"]
455
- ));
545
+ catch (e) {}
546
+
547
+ try {
548
+ await download(
549
+ `https://packages.microsoft.com/repos/edge/pool/main/m/microsoft-edge-${channel}/${filename}`,
550
+ archive
551
+ );
456
552
  }
457
- else if (process.platform == "linux") {
458
- ({stdout} = await promisify(execFile)("microsoft-edge", ["--version"]));
553
+ catch (err) {
554
+ throw new Error(`Edge download failed: ${err}`);
459
555
  }
556
+ await promisify(exec)(`dpkg -i ${archive}`);
460
557
 
461
- let version = stdout.trim().replace(/.*\s/, "");
558
+ return {binary, versionNumber};
559
+ }
560
+
561
+ static async #getInstalledVersionNumber(binary) {
562
+ let installedVersion = await Edge.getInstalledVersion(binary);
563
+ return installedVersion.replace("beta", "").replace("dev", "")
564
+ .trim().replace(/.*\s/, "");
565
+ }
566
+
567
+ static async #installDriver() {
568
+ let binary = {
569
+ win32:
570
+ "${Env:ProgramFiles(x86)}\\Microsoft\\Edge\\Application\\msedge.exe",
571
+ darwin: "/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge",
572
+ linux: "microsoft-edge"
573
+ }[process.platform];
574
+ let version = await Edge.#getInstalledVersionNumber(binary);
462
575
  if (!version)
463
576
  throw new Error("Edge is not installed");
464
577
 
465
- // Based on "node_modules/msedgedriver/install.js", adding a fallback
466
- // mechanism when msedgedriver doesn't exist for the latest Edge version.
467
- let platform = `${process.platform}-${process.arch}`;
468
- let buildTypes = {
578
+ let [zip, driver] = {
469
579
  "win32-ia32": ["edgedriver_win32.zip", "msedgedriver.exe"],
470
580
  "win32-x64": ["edgedriver_win64.zip", "msedgedriver.exe"],
471
581
  "linux-x64": ["edgedriver_linux64.zip", "msedgedriver"],
472
582
  "darwin-x64": ["edgedriver_mac64.zip", "msedgedriver"],
473
583
  "darwin-arm64": ["edgedriver_arm64.zip", "msedgedriver"]
474
- };
475
- let [zip, driver] = buildTypes[platform];
584
+ }[platform];
476
585
  let cacheDir = path.join(snapshotsBaseDir, "edge", "cache");
477
586
  let driverBinDir = path.join(process.cwd(), "node_modules", Edge.#DRIVER,
478
587
  "bin");
@@ -498,17 +607,11 @@ class Edge {
498
607
  throw new Error(`msedgedriver was not found for Edge ${version}`);
499
608
 
500
609
  await extractZip(archive, {dir: cacheDir});
610
+ await killDriverProcess(Edge.#DRIVER);
501
611
  for (let destinationDir of [driverBinDir, driverLibDir]) {
502
612
  await fs.promises.mkdir(destinationDir, {recursive: true});
503
- try {
504
- await fs.promises.copyFile(path.join(cacheDir, driver),
505
- path.join(destinationDir, driver));
506
- }
507
- catch (err) {
508
- // https://gitlab.com/eyeo/developer-experience/get-browser-binary/-/issues/3
509
- if (!err.toString().includes("copyfile"))
510
- throw err;
511
- }
613
+ await fs.promises.copyFile(path.join(cacheDir, driver),
614
+ path.join(destinationDir, driver));
512
615
  }
513
616
  }
514
617
 
@@ -516,8 +619,8 @@ class Edge {
516
619
  static async getDriver(version, {headless = true, extensionPaths = [],
517
620
  incognito = false, insecure = false,
518
621
  extraArgs = []} = {}) {
519
- if (incognito)
520
- throw new Error(ERROR_INCOGNITO_NOT_SUPPORTED);
622
+ if (process.platform == "linux")
623
+ await Edge.downloadBinary(version);
521
624
 
522
625
  await Edge.#installDriver();
523
626
 
@@ -526,6 +629,8 @@ class Edge {
526
629
  args.push("headless");
527
630
  if (extensionPaths.length > 0)
528
631
  args.push(`load-extension=${extensionPaths.join(",")}`);
632
+ if (incognito)
633
+ args.push("incognito");
529
634
 
530
635
  let builder = new webdriver.Builder();
531
636
  builder.forBrowser("MicrosoftEdge");
@@ -538,8 +643,245 @@ class Edge {
538
643
 
539
644
  return builder.build();
540
645
  }
646
+
647
+ /** @see Browser.enableExtensionInIncognito */
648
+ static async enableExtensionInIncognito(driver, extensionTitle) {
649
+ let version = await getBrowserVersion(driver);
650
+ if (version < 79)
651
+ // The UI workaround needs a chromium based msedgedriver
652
+ throw new Error(`Only supported on Edge >= 79. Current version: ${version}`);
653
+
654
+ await driver.navigate().to("edge://extensions/");
655
+ for (let elem of await driver.findElements(By.css("[role=listitem]"))) {
656
+ let text = await elem.getAttribute("innerHTML");
657
+ if (!text.includes(extensionTitle))
658
+ continue;
659
+
660
+ for (let button of await elem.findElements(By.css("button"))) {
661
+ text = await elem.getAttribute("innerHTML");
662
+ if (!text.includes("Details"))
663
+ continue;
664
+
665
+ await button.click();
666
+ return await driver.findElement(By.id("itemAllowIncognito")).click();
667
+ }
668
+ throw new Error("Details button not found");
669
+ }
670
+ throw new Error(`Extension "${extensionTitle}" not found`);
671
+ }
541
672
  }
542
673
 
674
+ /**
675
+ * Download functionality for Opera. This class can be used statically.
676
+ * @hideconstructor
677
+ * @extends Browser
678
+ */
679
+ class Opera extends Browser {
680
+ static async #getVersionForChannel(version) {
681
+ let channelPath = "opera/desktop";
682
+ let filePrefix = "Opera";
683
+ if (version != "latest")
684
+ return {versionNumber: version, channelPath, filePrefix};
685
+
686
+ let {body} = await got(`https://ftp.opera.com/pub/${channelPath}`);
687
+ let regex = /href="(\d.*)\/"/gm;
688
+ let matches = body.match(regex);
689
+ let versionNumber = regex.exec(matches[matches.length - 1])[1];
690
+
691
+ return {versionNumber, channelPath, filePrefix};
692
+ }
693
+
694
+ static #getOperaBinary(dir) {
695
+ switch (process.platform) {
696
+ case "win32":
697
+ return path.join(dir, "launcher.exe");
698
+ case "linux":
699
+ return path.join("/", "usr", "bin", "opera");
700
+ case "darwin":
701
+ return path.join(dir, "Opera.app", "Contents", "MacOS", "Opera");
702
+ default:
703
+ throw new Error(`Unexpected platform: ${process.platform}`);
704
+ }
705
+ }
706
+
707
+ static async #extractDeb(archive) {
708
+ let child = spawn("dpkg", ["-i", archive]);
709
+
710
+ child.stdout.on("data", data => {
711
+ if (data.toString().startsWith("Do you want to update Opera")) {
712
+ process.stdin.pipe(child.stdin);
713
+ child.stdin.write("no\r\n");
714
+ }
715
+ });
716
+
717
+ child.stderr.on("data", data => {
718
+ let expectedWarnings = [
719
+ "debconf: unable to initialize frontend",
720
+ "dpkg: warning: downgrading opera-stable",
721
+ "update-alternatives",
722
+ "using /usr/bin/opera",
723
+ "skip creation of",
724
+ "\r\n"
725
+ ];
726
+ if (!expectedWarnings.find(err => data.toString().includes(err.trim())))
727
+ console.error(`stderr: ${data.toString()}`);
728
+ });
729
+
730
+ await new Promise((resolve, reject) => child.on("close", code => {
731
+ if (code != 0)
732
+ reject(`dpkg process exited with code ${code}`);
733
+
734
+ resolve();
735
+ }));
736
+ }
737
+
738
+ static async #installOnWindows(archive, dir, filename) {
739
+ let archiveCopy = path.join(dir, filename);
740
+ await fsExtra.copy(archive, archiveCopy);
741
+ await promisify(exec)(`"${archiveCopy}"`);
742
+ }
743
+
744
+ static #extractOperaArchive(archive, dir, filename) {
745
+ switch (process.platform) {
746
+ case "win32":
747
+ return Opera.#installOnWindows(archive, dir, filename);
748
+ case "linux":
749
+ return Opera.#extractDeb(archive, dir);
750
+ case "darwin":
751
+ return extractTar(archive, dir);
752
+ default:
753
+ throw new Error(`Unexpected platform: ${process.platform}`);
754
+ }
755
+ }
756
+
757
+ /**
758
+ * Downloads the browser binary file.
759
+ * @param {string} version - Either "latest" or a full version number
760
+ * (i.e. "64.0.3417.92"). Defaults to "latest".
761
+ * @return {BrowserBinary}
762
+ */
763
+ static async downloadBinary(version = "latest") {
764
+ let {versionNumber, channelPath, filePrefix} =
765
+ await Opera.#getVersionForChannel(version);
766
+
767
+ let [platformDir, fileSuffix] = {
768
+ "win32-ia32": ["win", "Autoupdate.exe"],
769
+ "win32-x64": ["win", "Autoupdate_x64.exe"],
770
+ "linux-x64": ["linux", "amd64.deb"],
771
+ "darwin-x64": ["mac", "Autoupdate.tar.xz"],
772
+ "dawrin-arm64": ["mac", "Autoupdate_arm64.tar.xz"]
773
+ }[platform];
774
+
775
+ let snapshotsDir = path.join(snapshotsBaseDir, "opera");
776
+ let browserDir = path.join(snapshotsDir, `opera-${platform}-${versionNumber}`);
777
+ let filename = `${filePrefix}_${versionNumber}_${fileSuffix}`;
778
+ let archive = path.join(snapshotsDir, "cache", filename);
779
+
780
+ let binary = Opera.#getOperaBinary(browserDir);
781
+ try {
782
+ await fs.promises.access(browserDir);
783
+ return {binary, versionNumber};
784
+ }
785
+ catch (e) {}
786
+
787
+ await fs.promises.mkdir(path.dirname(browserDir), {recursive: true});
788
+ try {
789
+ await fs.promises.access(archive);
790
+ }
791
+ catch (e) {
792
+ await download(
793
+ `https://ftp.opera.com/pub/${channelPath}/${versionNumber}/${platformDir}/${filename}`,
794
+ archive
795
+ );
796
+ }
797
+
798
+ await Opera.#extractOperaArchive(archive, browserDir, filename);
799
+ return {binary, versionNumber};
800
+ }
801
+
802
+ static async #installDriver(version, originalVersion) {
803
+ let [zip, driver] = {
804
+ "win32-ia32": ["operadriver_win32.zip", "operadriver.exe"],
805
+ "win32-x64": ["operadriver_win64.zip", "operadriver.exe"],
806
+ "linux-x64": ["operadriver_linux64.zip", "operadriver"],
807
+ "darwin-x64": ["operadriver_mac64.zip", "operadriver"],
808
+ "darwin-arm64": ["operadriver_mac64.zip", "operadriver"]
809
+ }[platform];
810
+
811
+ let {versionNumber} = await Opera.#getVersionForChannel(version);
812
+ versionNumber = versionNumber.split(".")[0];
813
+
814
+ let cacheDir = path.join(snapshotsBaseDir, "opera", "cache",
815
+ `driver-for-opera-${versionNumber}`);
816
+ let archive = path.join(cacheDir, zip);
817
+
818
+ let {body} = await got(`https://github.com/operasoftware/operachromiumdriver/releases?q=Opera+${versionNumber}&expanded=true`);
819
+ let regex = /release-card[\s\S]*Link--primary.*>(.*)<\/a/gm;
820
+ let matches = body.match(regex);
821
+ if (!matches || matches.length == 0)
822
+ throw new Error(`Driver for Opera ${version} was not found`);
823
+ let driverVersion = regex.exec(matches[matches.length - 1])[1];
824
+
825
+ try {
826
+ await download(`https://github.com/operasoftware/operachromiumdriver/releases/download/v.${driverVersion}/${zip}`,
827
+ archive);
828
+ }
829
+ catch (err) {
830
+ throw new Error(`Downloading operadriver failed: ${err}`);
831
+ }
832
+
833
+ await killDriverProcess("operadriver");
834
+ let driverPath = path.join(cacheDir, zip.split(".")[0], driver);
835
+ try {
836
+ await fs.promises.rm(driverPath, {recursive: true});
837
+ }
838
+ catch (e) {} // file does not exist
839
+ await extractZip(archive, {dir: cacheDir});
840
+ await fs.promises.chmod(driverPath, 577);
841
+
842
+ return driverPath;
843
+ }
844
+
845
+ /** @see Browser.getDriver */
846
+ static async getDriver(version = "latest", {
847
+ headless = true, extensionPaths = [], incognito = false, insecure = false,
848
+ extraArgs = []
849
+ } = {}) {
850
+ let {binary, versionNumber} = await Opera.downloadBinary(version);
851
+ let driverPath = await Opera.#installDriver(versionNumber, version);
852
+ // operadriver uses chrome as a service builder:
853
+ // https://github.com/operasoftware/operachromiumdriver/blob/master/docs/desktop.md
854
+ let serviceBuilder = new chrome.ServiceBuilder(driverPath);
855
+ let service = serviceBuilder.build();
856
+ await service.start();
857
+
858
+ let options = new chrome.Options().addArguments("no-sandbox", ...extraArgs);
859
+ if (extensionPaths.length > 0)
860
+ options.addArguments(`load-extension=${extensionPaths.join(",")}`);
861
+ if (headless)
862
+ options.headless();
863
+ if (insecure)
864
+ options.addArguments("ignore-certificate-errors");
865
+ if (incognito)
866
+ options.addArguments("incognito");
867
+ options.setChromeBinaryPath(binary);
868
+ // https://github.com/operasoftware/operachromiumdriver/issues/61#issuecomment-579331657
869
+ options.addArguments("remote-debugging-port=9222");
870
+
871
+ let builder = new webdriver.Builder();
872
+ builder.forBrowser("chrome");
873
+ builder.setChromeOptions(options);
874
+ builder.setChromeService(serviceBuilder);
875
+
876
+ return builder.build();
877
+ }
878
+
879
+ /** @see Browser.enableExtensionInIncognito */
880
+ static async enableExtensionInIncognito(driver, extensionTitle) {
881
+ // Extensions page in Opera has the same web elements as Chromium
882
+ await Chromium.enableExtensionInIncognito(driver, extensionTitle);
883
+ }
884
+ }
543
885
 
544
886
  /**
545
887
  * @type {Object}
@@ -550,5 +892,6 @@ class Edge {
550
892
  export const BROWSERS = {
551
893
  chromium: Chromium,
552
894
  firefox: Firefox,
553
- edge: Edge
895
+ edge: Edge,
896
+ opera: Opera
554
897
  };