mirror of
https://github.com/angular/angular.git
synced 2026-09-14 13:54:52 +08:00
dc62446ef7
Migrate formatting to prettier for tools directory from clang-format PR Close #53947
357 lines
12 KiB
TypeScript
357 lines
12 KiB
TypeScript
/**
|
|
* @license
|
|
* Copyright Google LLC All Rights Reserved.
|
|
*
|
|
* Use of this source code is governed by an MIT-style license that can be
|
|
* found in the LICENSE file at https://angular.io/license
|
|
*/
|
|
|
|
import chalk from 'chalk';
|
|
import {Builder, WebDriver} from 'selenium-webdriver4';
|
|
|
|
import {Browser, getUniqueId} from '../browser';
|
|
|
|
import {IpcServer} from './ipc';
|
|
import {openSauceConnectTunnel} from './sauce-connect-tunnel';
|
|
|
|
const defaultCapabilities = {
|
|
// TODO: Turn off long-term. Right now this is just for debugging.
|
|
recordVideo: true,
|
|
recordScreenshots: false,
|
|
idleTimeout: 1000,
|
|
// These represent the maximum values supported by Saucelabs.
|
|
// See: https://wiki.saucelabs.com/display/DOCS/Test+Configuration+Options
|
|
commandTimeout: 600,
|
|
maxDuration: 10800,
|
|
extendedDebugging: true,
|
|
};
|
|
|
|
interface RemoteBrowser {
|
|
id: string;
|
|
state: 'claimed' | 'free' | 'launching';
|
|
driver: WebDriver | null;
|
|
sessionUrl: string | null;
|
|
}
|
|
|
|
interface BrowserTest {
|
|
testId: number;
|
|
pageUrl: string;
|
|
requestedBrowserId: string;
|
|
}
|
|
|
|
/**
|
|
* The SaucelabsDaemon daemon service class. This class handles the logic of connecting
|
|
* to the Saucelabs tunnel and provisioning browsers for tests. Provisioned browsers
|
|
* are re-used for subsequent tests. Their states are tracked so that new test
|
|
* requests are assigned to browsers that are currently `free` or `launching`.
|
|
*/
|
|
export class SaucelabsDaemon {
|
|
/**
|
|
* Map of browsers and their pending tests. If a browser is acquired on the
|
|
* remote selenium server, the browser is not immediately ready. If the browser
|
|
* becomes active, the pending tests will be started.
|
|
*/
|
|
private _pendingTests = new Map<RemoteBrowser, BrowserTest>();
|
|
|
|
/** List of active browsers that are managed by the daemon. */
|
|
private _activeBrowsers: RemoteBrowser[] = [];
|
|
|
|
/** Map that contains test ids with their claimed browser. */
|
|
private _runningTests = new Map<number, RemoteBrowser>();
|
|
|
|
/** Server used for communication with the Karma launcher. */
|
|
private _server = new IpcServer(this);
|
|
|
|
/** Base selenium capabilities that will be added to each browser. */
|
|
private _baseCapabilities = {...defaultCapabilities, ...this._userCapabilities};
|
|
|
|
/** Id of the keep alive interval that ensures no remote browsers time out. */
|
|
private _keepAliveIntervalId: NodeJS.Timeout | null = null;
|
|
|
|
/* Promise indicating whether we the tunnel is active, or if we are still connecting. */
|
|
private _connection: Promise<void> | undefined = undefined;
|
|
|
|
/* Number of parallel executions started */
|
|
private _parallelExecutions: number = 0;
|
|
|
|
constructor(
|
|
private _username: string,
|
|
private _accessKey: string,
|
|
private _buildName: string,
|
|
private _browsers: Browser[],
|
|
private _maxParallelExecutions: number,
|
|
private _sauceConnect: string,
|
|
private _userCapabilities: object = {},
|
|
) {
|
|
// Starts the keep alive loop for all active browsers, running every 15 seconds.
|
|
this._keepAliveIntervalId = setInterval(() => this._keepAliveBrowsers(), 15_000);
|
|
}
|
|
|
|
/**
|
|
* Connects the daemon to Saucelabs.
|
|
* This is typically done when the first test is started so that no connection is made
|
|
* if all tests are cache hits.
|
|
*/
|
|
async connectTunnel() {
|
|
if (!this._connection) {
|
|
this._connection = this._connect();
|
|
}
|
|
return this._connection;
|
|
}
|
|
|
|
/**
|
|
* Quits all active browsers.
|
|
*/
|
|
async quitAllBrowsers() {
|
|
let quitBrowsers: Promise<void>[] = [];
|
|
this._activeBrowsers.forEach((b) => {
|
|
if (b.driver) {
|
|
quitBrowsers.push(b.driver.quit());
|
|
}
|
|
});
|
|
await Promise.all(quitBrowsers);
|
|
this._activeBrowsers = [];
|
|
this._runningTests.clear();
|
|
this._pendingTests.clear();
|
|
}
|
|
|
|
/**
|
|
* Shutdown the daemon.
|
|
*
|
|
* Awaits the shutdown of browsers.
|
|
*/
|
|
async shutdown() {
|
|
await this.quitAllBrowsers();
|
|
if (this._keepAliveIntervalId !== null) {
|
|
clearInterval(this._keepAliveIntervalId);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* End a browser test if it is running.
|
|
*/
|
|
endTest(testId: number) {
|
|
if (!this._runningTests.has(testId)) {
|
|
return;
|
|
}
|
|
|
|
const browser = this._runningTests.get(testId)!;
|
|
browser.state = 'free';
|
|
this._runningTests.delete(testId);
|
|
}
|
|
|
|
/**
|
|
* Start a test on a remote browser.
|
|
*
|
|
* If the daemon has not yet initiated the saucelabs tunnel creation and browser launching then
|
|
* this initiates that process and awaits until it succeeds or fails.
|
|
*
|
|
* If the daemon has already initiated the saucelabs tunnel creation and browser launching
|
|
* but it is not yet complete then this blocks until that succeeds or fails.
|
|
*
|
|
* If all matching browsers are occupied with other tests then test is not run. Promise returns
|
|
* false.
|
|
*
|
|
* If there is a matching browser that are still launching then the test is scheduled to run
|
|
* on the browser when it is ready. Promise returns true.
|
|
*
|
|
* If there is a matching browser that is available the test it started. Promise returns true.
|
|
*/
|
|
async startTest(test: BrowserTest): Promise<boolean> {
|
|
await this.connectTunnel();
|
|
|
|
if (this._parallelExecutions < this._maxParallelExecutions) {
|
|
// Start additional browsers on each test start until the max parallel executions are
|
|
// reached to avoid the race condition of starting a browser and then having another test
|
|
// start steal it before is claimed by this test.
|
|
await this.launchBrowserSet();
|
|
}
|
|
|
|
const browser = this._findAvailableBrowser(test.requestedBrowserId);
|
|
if (!browser) {
|
|
console.error(`No available browser ${test.requestedBrowserId} for test ${test.testId}!`);
|
|
return false;
|
|
}
|
|
|
|
if (browser.state == 'launching') {
|
|
this._pendingTests.set(browser, test);
|
|
} else {
|
|
this._startBrowserTest(browser, test);
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* @internal
|
|
* Connects the daemon to Saucelabs.
|
|
* This is typically done when the first test is started so that no connection is made
|
|
* if all tests are cache hits.
|
|
**/
|
|
private async _connect() {
|
|
await openSauceConnectTunnel(
|
|
(this._userCapabilities as any).tunnelIdentifier,
|
|
this._sauceConnect,
|
|
);
|
|
}
|
|
|
|
/**
|
|
* @internal
|
|
* Launches a set of browsers and increments the count of parallel browser started. If there are
|
|
* pending tests waiting for a particular browser to launch before they can start, those tests are
|
|
* started once the browser is launched.
|
|
**/
|
|
private async launchBrowserSet() {
|
|
this._parallelExecutions++;
|
|
console.debug(
|
|
`Launching browsers set ${this._parallelExecutions} of ${this._maxParallelExecutions}...`,
|
|
);
|
|
|
|
// Once the tunnel is established we can launch browsers
|
|
await Promise.all(
|
|
this._browsers.map(async (browser, id) => {
|
|
const browserId = getUniqueId(browser);
|
|
const launched: RemoteBrowser = {
|
|
state: 'launching',
|
|
driver: null,
|
|
sessionUrl: null,
|
|
id: browserId,
|
|
};
|
|
const browserDescription = `${this._buildName} - ${browser.browserName} - #${id + 1}`;
|
|
|
|
const capabilities: any = {
|
|
'browserName': browser.browserName,
|
|
'sauce:options': {...this._baseCapabilities, ...browser},
|
|
};
|
|
|
|
// Set `sauce:options` to provide a build name for the remote browser instances.
|
|
// This helps with debugging. Also ensures the W3C protocol is used.
|
|
// See. https://wiki.saucelabs.com/display/DOCS/Test+Configuration+Options
|
|
capabilities['sauce:options']['name'] = browserDescription;
|
|
capabilities['sauce:options']['build'] = browserDescription;
|
|
|
|
console.debug(
|
|
`Capabilities for ${browser.browserName}:`,
|
|
JSON.stringify(capabilities, null, 2),
|
|
);
|
|
console.debug(` > Browser-ID: `, browserId);
|
|
console.debug(` > Browser-Description: `, browserDescription);
|
|
|
|
// Keep track of the launched browser. We do this before it even completed the
|
|
// launch as we can then handle scheduled tests when the browser is still launching.
|
|
this._activeBrowsers.push(launched);
|
|
|
|
// See the following link for public API of the selenium server.
|
|
// https://wiki.saucelabs.com/display/DOCS/Instant+Selenium+Node.js+Tests
|
|
const driver = await new Builder()
|
|
.withCapabilities(capabilities)
|
|
.usingServer(
|
|
`http://${this._username}:${this._accessKey}@ondemand.saucelabs.com:80/wd/hub`,
|
|
)
|
|
.build();
|
|
|
|
// Only wait 30 seconds to load a test page.
|
|
await driver.manage().setTimeouts({pageLoad: 30000});
|
|
|
|
const sessionId = (await driver.getSession()).getId();
|
|
|
|
// Mark the browser as available after launch completion.
|
|
launched.state = 'free';
|
|
launched.driver = driver;
|
|
launched.sessionUrl = `https://saucelabs.com/tests/${sessionId}`;
|
|
|
|
console.info(
|
|
chalk.yellow(
|
|
`Started browser ${browser.browserName} on Saucelabs: ${launched.sessionUrl}`,
|
|
),
|
|
);
|
|
|
|
// If a test has been scheduled before the browser completed launching, run
|
|
// it now given that the browser is ready now.
|
|
if (this._pendingTests.has(launched)) {
|
|
const test = this._pendingTests.get(launched)!;
|
|
this._pendingTests.delete(launched);
|
|
this._startBrowserTest(launched, test);
|
|
}
|
|
}),
|
|
);
|
|
}
|
|
|
|
/**
|
|
* @internal
|
|
* Starts a browser test on a browser.
|
|
* This sets the browser's state to "claimed" and navigates the browser to the test URL.
|
|
**/
|
|
private _startBrowserTest(browser: RemoteBrowser, test: BrowserTest) {
|
|
// We're not interested in awaiting on starting the browser test since that would delay
|
|
// starting additional browsers. Failures in browser test starts are silently ignored in
|
|
// the daemon. The karma test itself should fail or timeout if there are issues starting
|
|
// the browser test.
|
|
(async () => {
|
|
this._runningTests.set(test.testId, browser);
|
|
browser.state = 'claimed';
|
|
|
|
try {
|
|
console.debug(`Opening test url for #${test.testId}: ${test.pageUrl}`);
|
|
console.debug(` > Instance URL: ${browser.sessionUrl}`);
|
|
await browser.driver!.get(test.pageUrl);
|
|
const pageTitle = await browser.driver!.getTitle();
|
|
console.debug(`Test page loaded for #${test.testId}: "${pageTitle}".`);
|
|
} catch (e) {
|
|
console.error('Could not start browser test with id', test.testId, test.pageUrl);
|
|
}
|
|
})();
|
|
}
|
|
|
|
/**
|
|
* @internal
|
|
* Given a browserId, returns a browser that matches the browserId and is free
|
|
* or launching with no pending test. If no such browser if found, returns
|
|
* null.
|
|
**/
|
|
private _findAvailableBrowser(browserId: string): RemoteBrowser | null {
|
|
for (const browser of this._activeBrowsers) {
|
|
// If the browser ID doesn't match, continue searching.
|
|
if (browser.id !== browserId) {
|
|
continue;
|
|
}
|
|
|
|
// If the browser is claimed, continue searching.
|
|
if (browser.state === 'claimed') {
|
|
continue;
|
|
}
|
|
|
|
// If the browser is launching, check if it can be pre-claimed so that
|
|
// the test starts once the browser is ready. If it's already claimed,
|
|
// continue searching.
|
|
if (browser.state === 'launching' && this._pendingTests.has(browser)) {
|
|
continue;
|
|
}
|
|
|
|
return browser;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* @internal
|
|
* Implements a heartbeat for Saucelabs browsers as they could end up not receiving any
|
|
* commands when the daemon is unused (i.e. Bazel takes a while to start tests).
|
|
* https://saucelabs.com/blog/selenium-tips-how-to-coordinate-multiple-browsers-in-sauce-ondemand.
|
|
**/
|
|
private async _keepAliveBrowsers() {
|
|
const pendingCommands: Promise<string>[] = [];
|
|
this._activeBrowsers.forEach((b) => {
|
|
if (b.driver !== null) {
|
|
pendingCommands.push(b.driver.getTitle() as Promise<string>);
|
|
}
|
|
});
|
|
await Promise.all(pendingCommands);
|
|
console.debug(
|
|
`${Date().toLocaleString()}: Refreshed ${pendingCommands.length} browsers (pid ${
|
|
process.pid
|
|
}).`,
|
|
);
|
|
}
|
|
}
|