@sfcicd/browser
Version:
Browser automation library based on Playwright, designed for Salesforce CI/CD use cases.
418 lines • 17.6 kB
JavaScript
;
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.PageExtension = exports.webkit = exports.firefox = exports.chromium = void 0;
const playwright_1 = require("playwright");
const core_1 = require("@salesforce/core");
const axios_1 = __importDefault(require("axios"));
var SfPartitions;
(function (SfPartitions) {
SfPartitions["DEVELOP"] = "develop";
SfPartitions["DEMO"] = "demo";
SfPartitions["PATCH"] = "patch";
SfPartitions["SANDBOX"] = "sandbox";
SfPartitions["SCRATCH"] = "scratch";
SfPartitions["TRAILBLAZER"] = "trailblazer";
})(SfPartitions || (SfPartitions = {}));
function getBrowserType(browserType) {
if (browserType === 'chromium')
return playwright_1.chromium;
if (browserType === 'firefox')
return playwright_1.firefox;
if (browserType === 'webkit')
return playwright_1.webkit;
throw new Error(`Unknown BrowserType ${browserType}`);
}
const pwsf = {
async connectOverCDP(browserType, arg1, arg2) {
const browser = getBrowserType(browserType);
if (typeof arg1 === 'string') {
return BrowserExtension.create(await browser.connectOverCDP(arg1, arg2));
}
else {
return BrowserExtension.create(await browser.connectOverCDP(arg1));
}
},
async connect(browserType, arg1, arg2) {
const browser = getBrowserType(browserType);
if (typeof arg1 === 'string') {
return BrowserExtension.create(await browser.connect(arg1, arg2));
}
else {
return BrowserExtension.create(await browser.connect(arg1));
}
},
executablePath(browserType) {
return getBrowserType(browserType).executablePath();
},
async launch(browserType, options) {
const browser = getBrowserType(browserType);
return BrowserExtension.create(await browser.launch(options));
},
async launchPersistentContext(browserType, userDataDir, options) {
const browser = getBrowserType(browserType);
return ContextExtension.create(browser, await browser.launchPersistentContext(userDataDir, options));
},
async launchServer(browserType, options) {
return await getBrowserType(browserType).launchServer(options);
},
name(browserType) {
return getBrowserType(browserType).name();
},
};
exports.chromium = {
...pwsf,
connectOverCDP: (arg1, arg2) => pwsf.connectOverCDP('chromium', arg1, arg2),
connect: (arg1, arg2) => pwsf.connect('chromium', arg1, arg2),
executablePath: () => pwsf.executablePath('chromium'),
launch: (options) => pwsf.launch('chromium', options),
launchPersistentContext: (userDataDir, options) => pwsf.launchPersistentContext('chromium', userDataDir, options),
launchServer: (options) => pwsf.launchServer('chromium', options),
name: () => pwsf.name('chromium'),
};
exports.firefox = {
...pwsf,
connectOverCDP: (arg1, arg2) => pwsf.connectOverCDP('firefox', arg1, arg2),
connect: (arg1, arg2) => pwsf.connect('firefox', arg1, arg2),
executablePath: () => pwsf.executablePath('firefox'),
launch: (options) => pwsf.launch('firefox', options),
launchPersistentContext: (userDataDir, options) => pwsf.launchPersistentContext('firefox', userDataDir, options),
launchServer: (options) => pwsf.launchServer('firefox', options),
name: () => pwsf.name('firefox'),
};
exports.webkit = {
...pwsf,
connectOverCDP: (arg1, arg2) => pwsf.connectOverCDP('webkit', arg1, arg2),
connect: (arg1, arg2) => pwsf.connect('webkit', arg1, arg2),
executablePath: () => pwsf.executablePath('webkit'),
launch: (options) => pwsf.launch('webkit', options),
launchPersistentContext: (userDataDir, options) => pwsf.launchPersistentContext('webkit', userDataDir, options),
launchServer: (options) => pwsf.launchServer('webkit', options),
name: () => pwsf.name('webkit'),
};
/**
* Wrapper around Playwright Browser with proxy forwarding.
*/
class BrowserExtension {
constructor(browser) {
this.browser = browser;
}
/**
* Creates a proxied instance of BrowserExtension.
* @param browser Playwright Browser instance
*/
static create(browser) {
const instance = new BrowserExtension(browser);
return new Proxy(instance, {
get(target, prop, receiver) {
if (prop in target) {
return Reflect.get(target, prop, receiver);
}
const value = Reflect.get(browser, prop);
if (typeof value === 'function') {
return value.bind(browser);
}
return value;
}
});
}
/**
* Creates a new page in a fresh context.
*/
async newPage(options) {
return ContextExtension.create(this, await this.browser.newContext(options)).newPage();
}
/**
* Creates a new browser context.
* @param options Browser context options
*/
async newContext(options) {
return ContextExtension.create(this, await this.browser.newContext(options));
}
}
/**
* Wrapper around Playwright BrowserContext with proxy forwarding and added Salesforce login support.
*/
class ContextExtension {
constructor(browser, context) {
this.authedOrgs = new Map();
this.context = context;
this.Browser = browser;
}
/**
* Creates a proxied instance of ContextExtension.
* @param browser Browser instance
* @param context Playwright BrowserContext instance
*/
static create(browser, context) {
const instance = new ContextExtension(browser, context);
return new Proxy(instance, {
get(target, prop, receiver) {
if (prop in target) {
return Reflect.get(target, prop, receiver);
}
const value = Reflect.get(context, prop);
if (typeof value === 'function') {
return value.bind(context);
}
return value;
}
});
}
/**
* Creates a new page in this context.
*/
async newPage() {
let page;
const pages = this.context.pages();
if (pages.length == 1 && (pages[0].url()) == 'about:blank') {
page = pages[0];
}
else {
page = await this.context.newPage();
}
return PageExtension.create(this, page);
}
/**
* Perform a login to Salesforce org on the given page or a new page. Usually you do not have to call login()
* manually. It is called automatically when navigating to a salesforce page.
* If you are already logged in the method just returns - except you enforce a re-login.
* @param org Salesforce Org instance
* @param reLogin Perform a login even if we already did a login to that org within that browser context
* @param page Optional page to use for login
*/
login(org, reLogin = false, page) {
return new Promise(async (resolve, reject) => {
const username = org.getUsername();
if (!username) {
reject(new Error(`Something went wrong during SFCLI login. The Username of the org is undefined.`));
return;
}
if (!reLogin && this.authedOrgs.has(username)) {
resolve();
return;
}
const returnUrl = '/lightning/settings/personal/PersonalInformation/home';
const loginPage = page ? page : await this.context.newPage();
loginPage.on('framenavigated', async (frame) => {
const url = frame.url();
if (/\/ChangePassword\?/i.test(url)) {
const cancelButton = await frame.waitForSelector('#cancel-button', { timeout: 10000 }).catch(() => null);
if (cancelButton) {
await cancelButton.click();
}
else {
reject(new Error(`🛑 Redirected to password change page: ${url}`));
}
}
});
try {
await org.refreshAuth();
const conn = org.getConnection();
const response = await axios_1.default.post(`${conn.instanceUrl}/services/oauth2/singleaccess`, null, {
params: {
access_token: conn.accessToken,
redirect_uri: returnUrl
},
maxRedirects: 0,
validateStatus: status => status < 400
});
const frontdoorUrl = response.data.frontdoor_uri;
await loginPage.goto(frontdoorUrl);
await loginPage.waitForURL(`**${returnUrl}`, { timeout: 15000 });
this.authedOrgs.set(username, org);
if (loginPage !== page) {
await loginPage.close();
}
resolve();
}
catch (e) {
reject(e);
}
});
}
}
/**
* Wrapper around Playwright Page with proxy forwarding and Salesforce URL navigation support.
*/
class PageExtension {
constructor(context, page) {
this.page = page;
this.browserContext = context;
}
/**
* Creates a proxied instance of PageExtension.
* @param context BrowserContext instance
* @param page Playwright Page instance
*/
static create(context, page) {
const instance = new PageExtension(context, page);
return new Proxy(instance, {
get(target, prop, receiver) {
if (prop in target) {
return Reflect.get(target, prop, receiver);
}
const value = Reflect.get(page, prop);
if (typeof value === 'function') {
return value.bind(page);
}
return value;
}
});
}
/**
* Returns a Locator for any iframe on the page.
*/
anyFrameLocator() {
return this.page.frameLocator('iFrame');
}
context() {
return this.browserContext;
}
getPartition(conn) {
const hostnameParts = new URL(conn.instanceUrl).hostname.split('.');
const maybePartition = hostnameParts[hostnameParts.length - 4];
return Object.values(SfPartitions).includes(maybePartition)
? maybePartition
: undefined;
}
/**
* Navigate to a given URL.
* @param url URL string
* @param options Additional navigation options
*/
async goto(...args) {
if (typeof args[1] !== 'string') {
const [url, options] = args;
return this.page.goto(url, options);
}
else {
const [userName, urlType, path, options] = args;
const org = await core_1.Org.create({ aliasOrUsername: userName });
const conn = org.getConnection();
if (/^https?:\/\//.test(path))
throw new Error(`Please provide only the absolute path (without protocol and host). The protocol and host is added automatically matching your org and the given SfUrlType.`);
let trimmedPath = path.startsWith("/") ? path.slice(1) : path;
const partition = this.getPartition(conn);
const myDomain = (new URL(conn.instanceUrl).host).match(/^(.*?)(?=--|\.)/)?.[1];
if (!myDomain) {
throw new Error('Could not extract myDomain from connection instanceUrl');
}
const sandboxName = partition === SfPartitions.SANDBOX
? conn.instanceUrl.match(/--([^.]*)\./)?.[1]
: undefined;
const protocol = new URL(conn.instanceUrl).protocol;
const { domain, finalPath } = this.buildSalesforceUrl(urlType, myDomain, partition, sandboxName, options, trimmedPath);
const url = `${protocol}//${domain}/${finalPath}`;
await this.browserContext.login(org, false, this.page);
return this.page.goto(url, options);
}
}
/**
* https://help.salesforce.com/s/articleView?id=xcloud.domain_name_url_formats.htm&type=5
* @param urlType
* @param myDomain
* @param partition
* @param sandboxName
* @param options
* @param trimmedPath
* @returns
*/
buildSalesforceUrl(urlType, myDomain, partition, sandboxName, options, trimmedPath) {
// Helper to build subdomain string
function sfSubdomain(base, opts) {
let sub = base;
if (opts?.pkg)
sub += `--${opts.pkg}`;
if (opts?.uniqueId)
sub += `--${opts.uniqueId}`;
if (sandboxName)
sub += `--${sandboxName}`;
return sub;
}
// Helper to assert required options
function assertRequiredOption(opt, optName, context) {
if (!opt)
throw new Error(`Missing required option \`${optName}\` for ${context} URL type`);
}
let domain;
let finalPath = trimmedPath;
switch (urlType) {
case 'Login':
case 'ApplicationPageOrTab':
domain = `${sfSubdomain(myDomain)}.${partition ? partition + '.' : ''}my.salesforce.com`;
break;
case 'ContentFile':
domain = `${sfSubdomain(myDomain)}.${partition ? partition + '.' : ''}file.force.com`;
break;
case 'CmsPublicChannel':
domain = `${sfSubdomain(myDomain)}.${partition ? partition + '.' : ''}cdn.salesforce-experience.com`;
break;
case 'EmailTracking':
domain = `${sfSubdomain(myDomain)}.${partition ? partition + '.' : ''}my.sfdcopens.com`;
break;
case 'ExperienceCloudSite':
domain = `${sfSubdomain(myDomain)}.${partition ? partition + '.' : ''}my.site.com`;
break;
case 'ExperienceBuilder':
domain = `${sfSubdomain(myDomain)}.${partition ? partition + '.' : ''}builder.salesforce-experience.com`;
break;
case 'ExperienceBuilderPreview':
domain = `${sfSubdomain(myDomain)}.${partition ? partition + '.' : ''}preview.salesforce-experience.com`;
break;
case 'ExperienceBuilderLivePreview':
domain = `${sfSubdomain(myDomain)}.${partition ? partition + '.' : ''}live-preview.salesforce-experience.com`;
break;
case 'Lightning':
domain = `${sfSubdomain(myDomain)}.${partition ? partition + '.' : ''}lightning.force.com`;
break;
case 'LightningContainer': {
const pkg = options?.sfPackageName || 'c';
domain = `${sfSubdomain(myDomain, { pkg })}.${partition ? partition + '.' : ''}container.force.com`;
break;
}
case 'SalesforceSite': {
assertRequiredOption(options?.sfPageID, 'sfPageID', 'SalesforceSite');
domain = `${sfSubdomain(myDomain)}.${partition ? partition + '.' : ''}my.salesforce-sites.com`;
if (!trimmedPath.startsWith(options.sfPageID + '/') || trimmedPath === options.sfPageID) {
finalPath = options.sfPageID + '/' + trimmedPath;
}
break;
}
case 'SetupPage':
domain = `${sfSubdomain(myDomain)}.${partition ? partition + '.' : ''}my.salesforce-setup.com`;
break;
case 'ServiceCloudRealtime':
domain = `${sfSubdomain(myDomain)}.${partition ? partition + '.' : ''}my.salesforce-scrt.com`;
break;
case 'UserContent': {
assertRequiredOption(options?.sfUniqueID, 'sfUniqueID', 'UserContent');
domain = `${sfSubdomain(myDomain, { uniqueId: options.sfUniqueID })}.${partition ? partition + '.' : ''}my.force-user-content.com`;
break;
}
case 'UserContentGovCloud': {
assertRequiredOption(options?.sfUniqueID, 'sfUniqueID', 'UserContentGovCloud');
domain = `${sfSubdomain(myDomain, { uniqueId: options.sfUniqueID })}.${partition ? partition + '.' : ''}gia.force-user-content.com`;
break;
}
case 'UserImage': {
assertRequiredOption(options?.sfUniqueID, 'sfUniqueID', 'UserImage');
domain = `${sfSubdomain(myDomain, { uniqueId: options.sfUniqueID })}.${partition ? partition + '.' : ''}file.force-user-content.com`;
break;
}
case 'Visualforce': {
const vfPkg = options?.sfPackageName || 'c';
domain = `${sfSubdomain(myDomain, { pkg: vfPkg })}.${partition ? partition + '.' : ''}vf.force.com`;
break;
}
default:
domain = `${sfSubdomain(myDomain)}.${partition ? partition + '.' : ''}my.salesforce.com`;
break;
}
return { domain, finalPath };
}
}
exports.PageExtension = PageExtension;
//# sourceMappingURL=index.js.map