Skip to content

Commit

Permalink
Merge pull request #106 from amtrack/fix/frontdoor-workaround
Browse files Browse the repository at this point in the history
fix: workaround for timeouts and redirect issues on frontdoor
  • Loading branch information
amtrack authored Feb 25, 2019
2 parents 2ceeb32 + 04b085d commit bb26373
Show file tree
Hide file tree
Showing 21 changed files with 364 additions and 237 deletions.
184 changes: 147 additions & 37 deletions src/browserforce.ts
Original file line number Diff line number Diff line change
@@ -1,71 +1,181 @@
import { core } from '@salesforce/command';
import * as puppeteer from 'puppeteer';
import { URL } from 'url';
import { retry } from './plugins/utils';

class FrontdoorRedirectError extends Error {}
class LoginError extends Error {}
class RetryableError extends Error {}

const PERSONAL_INFORMATION_PATH =
'setup/personalInformationSetup.apexp?nooverride=1';

const ERROR_DIV_SELECTOR = '#errorTitle';
const ERROR_DIVS_SELECTOR = 'div.errorMsg';

export default class Browserforce {
public org: core.Org;
public logger: core.Logger;
public browser: puppeteer.Browser;
public page: puppeteer.Page;
constructor(org) {
constructor(org, logger?) {
this.org = org;
this.logger = logger;
}

public async login() {
this.browser = await puppeteer.launch({
args: ['--no-sandbox', '--disable-setuid-sandbox'],
headless: !(process.env.BROWSER_DEBUG === 'true')
});
this.page = await this.browser.newPage();
this.page.setDefaultNavigationTimeout(
parseInt(process.env.BROWSERFORCE_NAVIGATION_TIMEOUT_MS, 10) || 90000
);
await this.page.setViewport({ width: 1024, height: 768 });
const instanceUrl = this.getInstanceUrl();
const response = await this.page.goto(
`${instanceUrl}/secur/frontdoor.jsp?sid=${
await this.retryLogin(
`secur/frontdoor.jsp?sid=${
this.org.getConnection().accessToken
}&retURL=${encodeURIComponent(PERSONAL_INFORMATION_PATH)}`,
{ waitUntil: ['load', 'domcontentloaded', 'networkidle0'] }
}&retURL=${encodeURIComponent(PERSONAL_INFORMATION_PATH)}`
);
if (response) {
if (response.status() === 500) {
const errorHandle = await this.page.$(ERROR_DIV_SELECTOR);
if (errorHandle) {
const errorMsg = this.page.evaluate(
div => div.innerText,
errorHandle
);
throw new Error(`login failed [500]: ${errorMsg}`);
} else {
throw new Error(`login failed [500]: ${response.statusText()}`);
}
}
if (response.url().indexOf('/?ec=302') > 0) {
throw new Error(
`login failed [302]: {"instanceUrl": "${instanceUrl}, "url": "${response
.url()
.split('/')
.slice(0, 3)
.join('/')}"}"`
);
}
} else {
throw new Error('login failed');
}
return this;
}

public async logout() {
await this.page.close();
await this.browser.close();
return this;
}

public async resolveDomains() {
// resolve ip addresses of both LEX and classic domains
for (const url of [this.getInstanceUrl(), this.getLightningUrl()]) {
const resolver = await core.MyDomainResolver.create({
url: new URL(url)
});
await resolver.resolve();
}
}

public async throwResponseErrors(response) {
if (!response) {
throw new Error('no response');
}
if (!response.ok()) {
throw new RetryableError(`${response.status()}: ${response.statusText()}`);
}
if (response.url().indexOf('/?ec=302') > 0) {
if (
!response.url().startsWith(this.getInstanceUrl()) &&
!response.url().startsWith(this.getLightningUrl())
) {
const redactedUrl = response
.url()
.replace(/sid=(.*)/, 'sid=<REDACTED>')
.replace(/sid%3D(.*)/, 'sid=<REDACTED>');
throw new FrontdoorRedirectError(
`expected instance or lightning URL but got: ${redactedUrl}`
);
}
throw new LoginError('login failed');
}
}

public async throwPageErrors(page) {
const errorHandle = await page.$(ERROR_DIV_SELECTOR);
if (errorHandle) {
const errorMsg = await page.evaluate(
(div: HTMLDivElement) => div.innerText,
errorHandle
);
await errorHandle.dispose();
if (errorMsg && errorMsg.trim()) {
throw new Error(errorMsg.trim());
}
}
const errorElements = await page.$$(ERROR_DIVS_SELECTOR);
if (errorElements.length) {
const errorMessages = await page.evaluate((...errorDivs) => {
return errorDivs.map((div: HTMLDivElement) => div.innerText);
}, ...errorElements);
const errorMsg = errorMessages
.map(m => m.trim())
.join(' ')
.trim();
if (errorMsg) {
throw new Error(errorMsg);
}
}
}

// path instead of url
public async openPage(urlPath, options?) {
return await retry(
async () => {
try {
await this.resolveDomains();
} catch (error) {
throw new RetryableError(error);
}
const page = await this.browser.newPage();
page.setDefaultNavigationTimeout(
parseInt(process.env.BROWSERFORCE_NAVIGATION_TIMEOUT_MS, 10) || 90000
);
await page.setViewport({ width: 1024, height: 768 });
const url = `${this.getInstanceUrl()}/${urlPath}`;
const response = await page.goto(url, options);
await this.throwResponseErrors(response);
// await this.throwPageErrors(page);
return page;
},
5,
4000,
true,
RetryableError.prototype,
this.logger
);
}

public getInstanceUrl() {
return this.org.getConnection().instanceUrl;
}

private getLightningUrl() {
const myDomain = this.getInstanceUrl().match(/https?\:\/\/([^.]*)/)[1];
return `https://${myDomain}.lightning.force.com`;
}

private async retryLogin(
loginUrl,
refreshAuthLeft = 1,
frontdoorWorkaroundLeft = 1
) {
try {
await this.openPage(loginUrl, {
waitUntil: ['load', 'domcontentloaded', 'networkidle0']
});
} catch (err) {
if (err instanceof LoginError && refreshAuthLeft > 0) {
if (this.logger) {
this.logger.warn(`retrying with refreshed auth because of "${err}"`);
}
await this.org.refreshAuth();
await this.retryLogin(
loginUrl,
refreshAuthLeft - 1,
frontdoorWorkaroundLeft
);
} else if (
err instanceof FrontdoorRedirectError &&
frontdoorWorkaroundLeft > 0
) {
if (this.logger) {
this.logger.warn(
`retrying open page with instance url because of "${err}"`
);
}
await this.retryLogin(
PERSONAL_INFORMATION_PATH,
refreshAuthLeft,
frontdoorWorkaroundLeft - 1
);
} else {
throw err;
}
}
}
}
2 changes: 1 addition & 1 deletion src/browserforceCommand.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ export default class BrowserforceCommand extends SfdxCommand {
);
// TODO: use require.resolve to dynamically load plugins from npm packages
this.settings = ConfigParser.parse(DRIVERS, definition);
this.bf = new Browserforce(this.org);
this.bf = new Browserforce(this.org, this.ux.cli);
this.ux.startSpinner('logging in');
await this.bf.login();
this.ux.stopSpinner();
Expand Down
52 changes: 27 additions & 25 deletions src/plugins/customer-portal/availableCustomObjects/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,6 @@ interface CustomObjectRecord {

export default class CustomerPortalAvailableCustomObjects extends BrowserforcePlugin {
public async retrieve(definition?) {
const page = this.browserforce.page;
const response = [];
if (definition) {
const availableCustomObjectList = definition
Expand All @@ -33,7 +32,7 @@ export default class CustomerPortalAvailableCustomObjects extends BrowserforcePl
// BUG in jsforce: query acts with scanAll:true and returns deleted CustomObjects.
// It cannot be disabled.
// This will throw a timeout error waitingFor('#options_9')
await page.goto(this.browserforce.getInstanceUrl(), {
const page = await this.browserforce.openPage('', {
waitUntil: ['load', 'domcontentloaded', 'networkidle0']
});
// new URLs for LEX: https://help.salesforce.com/articleView?id=FAQ-for-the-New-URL-Format-for-Lightning-Experience-and-the-Salesforce-Mobile-App&type=1
Expand All @@ -59,17 +58,20 @@ export default class CustomerPortalAvailableCustomObjects extends BrowserforcePl
}
const classicUiPath = `${customObject.Id}/e`;
if (isLEX) {
const availableForCustomerPortalUrl = `${this.browserforce.getInstanceUrl()}/lightning/setup/ObjectManager/${
const availableForCustomerPortalPath = `lightning/setup/ObjectManager/${
customObject.Id
}/edit?nodeId=ObjectManager&address=${encodeURIComponent(
`/${classicUiPath}`
)}`;
await page.goto(availableForCustomerPortalUrl, {
waitUntil: ['load', 'domcontentloaded', 'networkidle0']
});
const lexPage = await this.browserforce.openPage(
availableForCustomerPortalPath,
{
waitUntil: ['load', 'domcontentloaded', 'networkidle0']
}
);
// maybe use waitForFrame https://github.com/GoogleChrome/puppeteer/issues/1361
await page.waitFor(SELECTORS.IFRAME);
const frame = await page
await lexPage.waitFor(SELECTORS.IFRAME);
const frame = await lexPage
.frames()
.find(f => f.name().startsWith('vfFrameId'));
await frame.waitFor(
Expand All @@ -85,16 +87,15 @@ export default class CustomerPortalAvailableCustomObjects extends BrowserforcePl
)
});
} else {
const availableForCustomerPortalUrl = `${this.browserforce.getInstanceUrl()}/${classicUiPath}`;
await page.goto(availableForCustomerPortalUrl);
await page.waitFor(
const classicPage = await this.browserforce.openPage(classicUiPath);
await classicPage.waitFor(
SELECTORS.CUSTOM_OBJECT_AVAILABLE_FOR_CUSTOMER_PORTAL
);
response.push({
id: customObject.Id,
name: customObject.DeveloperName,
namespacePrefix: customObject.NamespacePrefix,
available: await page.$eval(
available: await classicPage.$eval(
SELECTORS.CUSTOM_OBJECT_AVAILABLE_FOR_CUSTOMER_PORTAL,
(el: HTMLInputElement) => el.checked
)
Expand Down Expand Up @@ -135,9 +136,8 @@ export default class CustomerPortalAvailableCustomObjects extends BrowserforcePl
}

public async apply(plan) {
const page = this.browserforce.page;
if (plan && plan.length) {
await page.goto(this.browserforce.getInstanceUrl(), {
const page = await this.browserforce.openPage('', {
waitUntil: ['load', 'domcontentloaded', 'networkidle0']
});
// new URLs for LEX: https://help.salesforce.com/articleView?id=FAQ-for-the-New-URL-Format-for-Lightning-Experience-and-the-Salesforce-Mobile-App&type=1
Expand All @@ -149,17 +149,20 @@ export default class CustomerPortalAvailableCustomObjects extends BrowserforcePl
customObject.available ? 1 : 0
}&retURL=/${customObject.id}`;
if (isLEX) {
const availableForCustomerPortalUrl = `${this.browserforce.getInstanceUrl()}/lightning/setup/ObjectManager/${
const availableForCustomerPortalPath = `lightning/setup/ObjectManager/${
customObject.id
}/edit?nodeId=ObjectManager&address=${encodeURIComponent(
`/${classicUiPath}`
)}`;
await page.goto(availableForCustomerPortalUrl, {
waitUntil: ['load', 'domcontentloaded', 'networkidle0']
});
const lexPage = await this.browserforce.openPage(
availableForCustomerPortalPath,
{
waitUntil: ['load', 'domcontentloaded', 'networkidle0']
}
);
// maybe use waitForFrame https://github.com/GoogleChrome/puppeteer/issues/1361
await page.waitFor(SELECTORS.IFRAME);
const frame = await page
await lexPage.waitFor(SELECTORS.IFRAME);
const frame = await lexPage
.frames()
.find(f => f.name().startsWith('vfFrameId'));
await frame.waitFor(SELECTORS.SAVE_BUTTON);
Expand All @@ -169,12 +172,11 @@ export default class CustomerPortalAvailableCustomObjects extends BrowserforcePl
frame.click(SELECTORS.SAVE_BUTTON)
]);
} else {
const availableForCustomerPortalUrl = `${this.browserforce.getInstanceUrl()}/${classicUiPath}`;
await page.goto(availableForCustomerPortalUrl);
await page.waitFor(SELECTORS.SAVE_BUTTON);
const classicPage = await this.browserforce.openPage(classicUiPath);
await classicPage.waitFor(SELECTORS.SAVE_BUTTON);
await Promise.all([
page.waitForNavigation(),
page.click(SELECTORS.SAVE_BUTTON)
classicPage.waitForNavigation(),
classicPage.click(SELECTORS.SAVE_BUTTON)
]);
}
}
Expand Down
17 changes: 5 additions & 12 deletions src/plugins/customer-portal/enabled/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,19 +5,14 @@ const PATHS = {
};
const SELECTORS = {
ENABLED: '#penabled',
ERROR_DIV: '#errorTitle',
SAVE_BUTTON: 'input[name="save"]'
};

export default class CustomerPortalEnable extends BrowserforcePlugin {
public async retrieve(definition?) {
const page = this.browserforce.page;
await page.goto(`${this.browserforce.getInstanceUrl()}/${PATHS.EDIT_VIEW}`);
await page.waitFor(SELECTORS.ENABLED);
const customerPortalNotAvailable = await page.$(SELECTORS.ERROR_DIV);
if (customerPortalNotAvailable) {
throw new Error('Customer Portal is not available in this org');
}
const page = await this.browserforce.openPage(PATHS.EDIT_VIEW, {
waitUntil: ['load', 'domcontentloaded', 'networkidle0']
});
await page.waitFor(SELECTORS.ENABLED);
const response = await page.$eval(
SELECTORS.ENABLED,
Expand All @@ -36,11 +31,9 @@ export default class CustomerPortalEnable extends BrowserforcePlugin {
if (plan === false) {
throw new Error('`enabled` cannot be disabled once enabled');
}
const page = this.browserforce.page;

if (plan) {
await page.goto(
`${this.browserforce.getInstanceUrl()}/${PATHS.EDIT_VIEW}`
);
const page = await this.browserforce.openPage(PATHS.EDIT_VIEW);
await page.waitFor(SELECTORS.ENABLED);
await page.$eval(
SELECTORS.ENABLED,
Expand Down
Loading

0 comments on commit bb26373

Please sign in to comment.