diff --git a/browser_patches/firefox/repack-juggler.mjs b/browser_patches/firefox/repack-juggler.mjs new file mode 100755 index 0000000000..a26bacdecc --- /dev/null +++ b/browser_patches/firefox/repack-juggler.mjs @@ -0,0 +1,182 @@ +#!/usr/bin/env node + +import {spawn} from 'child_process'; +import * as path from 'path'; +import * as URL from 'url'; +import * as fs from 'fs'; +import * as http from 'http'; +import * as https from 'https'; + +const existsAsync = path => new Promise(resolve => fs.stat(path, err => resolve(!err))); + +const __filename = URL.fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +// Path to jar.mn in the juggler +const JARMN_PATH = path.join(__dirname, 'juggler', 'jar.mn'); +// Workdir for Firefox repackaging +const BUILD_DIRECTORY = `/tmp/repackaged-firefox`; +// Information about currently downloaded build +const BUILD_INFO_PATH = path.join(BUILD_DIRECTORY, 'build-info.json'); +// Backup OMNI.JA - the original one before repackaging. +const OMNI_BACKUP_PATH = path.join(BUILD_DIRECTORY, 'omni.ja.backup'); +// Workdir to extract omni.ja +const OMNI_EXTRACT_DIR = path.join(BUILD_DIRECTORY, 'omni'); +// Path inside omni.ja to juggler +const OMNI_JUGGLER_DIR = path.join(OMNI_EXTRACT_DIR, 'chrome', 'juggler'); + +const EXECUTABLE_PATHS = { + 'ubuntu18.04': ['firefox', 'firefox'], + 'ubuntu20.04': ['firefox', 'firefox'], + 'mac10.13': ['firefox', 'Nightly.app', 'Contents', 'MacOS', 'firefox'], + 'mac10.14': ['firefox', 'Nightly.app', 'Contents', 'MacOS', 'firefox'], + 'mac10.15': ['firefox', 'Nightly.app', 'Contents', 'MacOS', 'firefox'], + 'mac11': ['firefox', 'Nightly.app', 'Contents', 'MacOS', 'firefox'], + 'mac11-arm64': ['firefox', 'Nightly.app', 'Contents', 'MacOS', 'firefox'], + 'win32': ['firefox', 'firefox.exe'], + 'win64': ['firefox', 'firefox.exe'], +}; + +const buildNumber = (await fs.promises.readFile(path.join(__dirname, 'BUILD_NUMBER'), 'utf8')).split('\n').shift(); +const expectedBuilds = (await fs.promises.readFile(path.join(__dirname, 'EXPECTED_BUILDS'), 'utf8')).split('\n'); + +if (process.argv.length !== 3) { + console.error(`ERROR: pass build name to repackage - one of ${expectedBuilds.join(', ')}`); + process.exit(1); +} + +const buildName = process.argv[2]; +if (!expectedBuilds.includes(buildName)) { + console.error(`ERROR: expected argument to be one of ${expectedBuilds.join(', ')}, received - "${buildName}"`); + process.exit(1); +} + +const currentBuildInfo = await fs.promises.readFile(BUILD_INFO_PATH).then(text => JSON.parse(text)).catch(e => ({ buildName: '', buildNumber: '' })); + +if (currentBuildInfo.buildName !== buildName || currentBuildInfo.buildNumber !== buildNumber) { + await fs.promises.rm(BUILD_DIRECTORY, { recursive: true }).catch(e => {}); + await fs.promises.mkdir(BUILD_DIRECTORY); + const buildZipPath = path.join(BUILD_DIRECTORY, 'firefox.zip'); + console.log(`Downloading ${buildName} ${buildNumber} - it might take a few minutes`); + await downloadFile(`https://playwright.azureedge.net/builds/firefox/${buildNumber}/${buildName}`, buildZipPath); + await spawnAsync('unzip', [ buildZipPath ], {cwd: BUILD_DIRECTORY}); + await fs.promises.writeFile(BUILD_INFO_PATH, JSON.stringify({ buildNumber, buildName }), 'utf8'); +} + +// Find all omni.ja files in the Firefox build. +const omniPaths = await spawnAsync('find', ['.', '-name', 'omni.ja'], { + cwd: BUILD_DIRECTORY, +}).then(({stdout}) => stdout.trim().split('\n').map(aPath => path.join(BUILD_DIRECTORY, aPath))); + +// Iterate over all omni.ja files and find one that has juggler inside. +const omniWithJugglerPath = await (async () => { + for (const omniPath of omniPaths) { + const {stdout} = await spawnAsync('unzip', ['-Z1', omniPath], {cwd: BUILD_DIRECTORY}); + if (stdout.includes('chrome/juggler')) + return omniPath; + } + return null; +})(); + +if (!omniWithJugglerPath) { + console.error('ERROR: did not find omni.ja file with baked in Juggler!'); + process.exit(1); +} else { + if (!(await existsAsync(OMNI_BACKUP_PATH))) + await fs.promises.copyFile(omniWithJugglerPath, OMNI_BACKUP_PATH); +} + +// Let's repackage omni folder! +await fs.promises.rm(OMNI_EXTRACT_DIR, { recursive: true }).catch(e => {}); +await fs.promises.mkdir(OMNI_EXTRACT_DIR); + +await spawnAsync('unzip', [OMNI_BACKUP_PATH], {cwd: OMNI_EXTRACT_DIR }); +// Remove current juggler directory +await fs.promises.rm(OMNI_JUGGLER_DIR, { recursive: true }); +// Repopulate with tip-of-tree juggler files +const jarmn = await fs.promises.readFile(JARMN_PATH, 'utf8'); +const jarLines = jarmn.split('\n').map(line => line.trim()).filter(line => line.startsWith('content/') && line.endsWith(')')); +for (const line of jarLines) { + const tokens = line.split(/\s+/); + const toPath = path.join(OMNI_JUGGLER_DIR, tokens[0]); + const fromPath = path.join(__dirname, 'juggler', tokens[1].slice(1, -1)); + await fs.promises.mkdir(path.dirname(toPath), { recursive: true}); + await fs.promises.copyFile(fromPath, toPath); +} + +await fs.promises.rm(omniWithJugglerPath); +await spawnAsync('zip', ['-0', '-qr9XD', omniWithJugglerPath, '.'], {cwd: OMNI_EXTRACT_DIR, stdio: 'inherit'}); + +// Output executable path to be used in test. +const buildPlatform = buildName.substring('firefox-'.length).slice(0, -'.zip'.length).replace('-', ''); +console.log(` + buildName: ${buildName} + buildNumber: ${buildNumber} + executablePath: ${path.join(BUILD_DIRECTORY, ...EXECUTABLE_PATHS[buildPlatform])} +`); + + +function httpRequest(url, method, response) { + let options = URL.parse(url); + options.method = method; + + const requestCallback = res => { + if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) + httpRequest(res.headers.location, method, response); + else + response(res); + }; + const request = options.protocol === 'https:' ? + https.request(options, requestCallback) : + http.request(options, requestCallback); + request.end(); + return request; +} + +function downloadFile(url, destinationPath, progressCallback) { + let fulfill = ({error}) => {}; + let downloadedBytes = 0; + let totalBytes = 0; + + const promise = new Promise(x => { fulfill = x; }); + + const request = httpRequest(url, 'GET', response => { + if (response.statusCode !== 200) { + const error = new Error(`Download failed: server returned code ${response.statusCode}. URL: ${url}`); + // consume response data to free up memory + response.resume(); + fulfill({error}); + return; + } + const file = fs.createWriteStream(destinationPath); + file.on('finish', () => fulfill({error: null})); + file.on('error', error => fulfill({error})); + response.pipe(file); + totalBytes = parseInt(response.headers['content-length'], 10); + if (progressCallback) + response.on('data', onData); + }); + request.on('error', error => fulfill({error})); + return promise; + + function onData(chunk) { + downloadedBytes += chunk.length; + progressCallback(downloadedBytes, totalBytes); + } +} + +function spawnAsync(cmd, args, options) { + // console.log(cmd, ...args, 'CWD:', options.cwd); + const process = spawn(cmd, args, options); + + return new Promise(resolve => { + let stdout = ''; + let stderr = ''; + if (process.stdout) + process.stdout.on('data', data => stdout += data); + if (process.stderr) + process.stderr.on('data', data => stderr += data); + process.on('close', code => resolve({stdout, stderr, code})); + process.on('error', error => resolve({stdout, stderr, code: 0, error})); + }); +}