UNPKG

@sfcicd/browser

Version:

Browser automation library based on Playwright, designed for Salesforce CI/CD use cases.

418 lines 17.6 kB
"use strict"; 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