Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: added notification when scratch org is about to expire #4304

Merged
merged 14 commits into from
Jul 26, 2022
Merged
Show file tree
Hide file tree
Changes from 10 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 27 additions & 22 deletions packages/salesforcedx-vscode-core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,7 @@ import { isSfdxProjectOpened } from './predicates';
import { registerPushOrDeployOnSave, sfdxCoreSettings } from './settings';
import { taskViewService } from './statuses';
import { showTelemetryMessage, telemetryService } from './telemetry';
import { isCLIInstalled } from './util';
import { isCLIInstalled, setUpOrgExpirationWatcher } from './util';
import { OrgAuthInfo } from './util/authInfo';

const flagOverwrite: FlagParameter<string> = {
Expand Down Expand Up @@ -661,27 +661,7 @@ export async function activate(extensionContext: vscode.ExtensionContext) {
);

if (sfdxProjectOpened) {
await workspaceContext.initialize(extensionContext);

// register org picker commands
const orgList = new OrgList();
extensionContext.subscriptions.push(registerOrgPickerCommands(orgList));

await setupOrgBrowser(extensionContext);
await setupConflictView(extensionContext);

PersistentStorageService.initialize(extensionContext);

// Register filewatcher for push or deploy on save

await registerPushOrDeployOnSave();
decorators.showOrg();
decorators.monitorOrgConfigChanges();

// Demo mode Decorator
if (isDemoMode()) {
decorators.showDemoMode();
}
await initializeProject(extensionContext);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍🏽

}

// Commands
Expand Down Expand Up @@ -733,6 +713,31 @@ export async function activate(extensionContext: vscode.ExtensionContext) {
return api;
}

async function initializeProject(extensionContext: vscode.ExtensionContext) {
await workspaceContext.initialize(extensionContext);

// Register org picker commands
const orgList = new OrgList();
extensionContext.subscriptions.push(registerOrgPickerCommands(orgList));

await setupOrgBrowser(extensionContext);
await setupConflictView(extensionContext);

PersistentStorageService.initialize(extensionContext);

// Register file watcher for push or deploy on save
await registerPushOrDeployOnSave();
decorators.showOrg();
decorators.monitorOrgConfigChanges();

await setUpOrgExpirationWatcher();
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@randi274 Also, I initially had the impl for setUpOrgExpirationWatcher() in index.ts, but it wasn't testable (there are no unit tests for the code in this index.ts... it's all tested implicitly by the integration tests) so I moved setUpOrgExpirationWatcher() into its own file (packages/salesforcedx-vscode-core/src/util/orgUtil.ts) and created unit tests.


// Demo mode decorator
if (isDemoMode()) {
decorators.showDemoMode();
}
}

export function deactivate(): Promise<void> {
console.log('SFDX CLI Extension Deactivated');

Expand Down
8 changes: 7 additions & 1 deletion packages/salesforcedx-vscode-core/src/messages/i18n.ts
Original file line number Diff line number Diff line change
Expand Up @@ -643,5 +643,11 @@ export const messages = {
'Unable to rename the component. Try renaming the component manually and then redeploying your changes.',
error_function_type: 'Unable to determine type of executing function.',
error_unable_to_get_started_function:
'Unable to access the function in "{0}".'
'Unable to access the function in "{0}".',
Copy link
Contributor Author

@jeffb-sfdc jeffb-sfdc Jul 21, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This text was provided by @sbudhirajadoc

pending_org_expiration_expires_on_message:
'%s\n(expires on %s)',
pending_org_expiration_notification_message:
'Warning: One or more of your orgs expire in the next %s days. For more details, review the Output panel.',
pending_org_expiration_output_channel_message:
'Warning: The following orgs expire in the next %s days:\n\n%s\n\nIf these orgs contain critical data or settings, back them up before the org expires.'
};
4 changes: 4 additions & 0 deletions packages/salesforcedx-vscode-core/src/util/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,3 +20,7 @@ export {
hasRootWorkspace
} from './rootWorkspace';
export { MetadataDictionary, MetadataInfo } from './metadataDictionary';
export {
checkForExpiredOrgs,
setUpOrgExpirationWatcher
} from './orgUtil';
87 changes: 87 additions & 0 deletions packages/salesforcedx-vscode-core/src/util/orgUtil.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
/*
* Copyright (c) 2022, salesforce.com, inc.
* All rights reserved.
* Licensed under the BSD 3-Clause license.
* For full license text, see LICENSE.txt file in the repo root or https://opensource.org/licenses/BSD-3-Clause
*/

import { Aliases } from '@salesforce/core';
import { channelService } from '../channels';
import { nls } from '../messages';
import { notificationService } from '../notifications';
import { FileInfo, OrgList } from '../orgPicker';

export async function setUpOrgExpirationWatcher() {
// Run once to start off with.
await checkForExpiredOrgs();

/*
Comment this out for now. For now, we are only going to check once on activation,
however it would be helpful if we also checked once a day. If we decide to also
check once a day, uncomment the following code.

// And run again once every 24 hours.
setInterval(async () => {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This would be good, especially for users that keep their projects open.

Copy link
Contributor Author

@jeffb-sfdc jeffb-sfdc Jul 21, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

^THIS! - I 100% agree, but Ananya doesn't. An argument she brought up was that users complain that our extension is too noisy (too many notifications). The plan is to release w/only displaying on activation, and then see what users think.

await checkForExpiredOrgs();
}, 1000 * 60 * 60 * 24);
*/
}

export async function checkForExpiredOrgs() {
try {
const daysBeforeExpire = 5;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this something we could put behind a custom setting, and maybe allow for no reminder? I usually made my scratch orgs last 7 days (some folks I know make theirs only for 1-2), so it would be a bit more frequent/intrusive. Also cool if that's a better fit for a subsequent item 👍

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it would be good for a followup item. Let's see how the users like this feature, and how they use it. If we add the "also check daily" feature in, it would be good then to revisit this and maybe add a custom setting. I think for right now, I'd like to not make all the options user configurable (too many options = too many choices for the user)

const today = new Date();
const daysUntilExpiration = new Date();
daysUntilExpiration.setDate(daysUntilExpiration.getDate() + daysBeforeExpire);

const orgList = new OrgList();
const authInfoObjects = await orgList.getAuthInfoObjects();
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In the draft PR that updates orgList to not read from the .sfdx directory, the return type of this method changes to return the OrgAuthorization type from core.

FileInfo is a type that is created after reading the files in the ~/.sfdx directory.

That change would require some reworking of this methods internals, to work with OrgAuthorization type and the AuthFields type for auth fields that are stored there in core v3, like expiration date: https://github.com/forcedotcom/salesforcedx-vscode/pull/4297/files#diff-2b72c15ab3c2e3d66f269772e80aed9902d2d85d9acb2a462116d03541484d4fR97

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe this file could import and use AuthInfo.listAllAuthorizations()

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good find @klewis-sfdc! Looks like this one will be behind the core v3 change in the queue. @jeffb-sfdc did you want to merge that work in now, or wait til we finish sorting out all of the larger issues?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've spoke with Ken about this, and the plan is to merge in now and not be held up, then pull develop into his branch and then address this issue.


const orgsAboutToExpire = authInfoObjects!.filter((authInfoObject: FileInfo) => {
// Filter out the dev hubs.
if (authInfoObject.isDevHub) {
return false;
}

// Some dev hubs have isDevHub=false, but no expiration date, so filter them out.
if (!authInfoObject.expirationDate) {
return false;
}

// Filter out the expired orgs.
const expirationDate = new Date(authInfoObject.expirationDate);
if (expirationDate < today) {
return false;
}

// Now filter and only return the results that are within the 5 day window.
return expirationDate <= daysUntilExpiration;
});

if (orgsAboutToExpire.length < 1) {
return;
}

const defaultOptions = Aliases.getDefaultOptions();
const aliases = await Aliases.create(defaultOptions);

const formattedOrgsToDisplay = orgsAboutToExpire.map((orgAboutToExpire: any) => {
const alias = aliases.getKeysByValue(orgAboutToExpire.username);
const aliasName = alias.length > 0
? alias.toString()
: orgAboutToExpire.username;

return nls.localize('pending_org_expiration_expires_on_message', aliasName, orgAboutToExpire.expirationDate);
}).join('\n\n');

notificationService.showWarningMessage(
nls.localize('pending_org_expiration_notification_message', daysBeforeExpire)
);
channelService.appendLine(
nls.localize('pending_org_expiration_output_channel_message', daysBeforeExpire, formattedOrgsToDisplay)
);
channelService.showChannelOutput();
} catch (err) {
console.error(err);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
*/

import { assert, expect } from 'chai';
import { SinonStub, stub } from 'sinon';
import { stub } from 'sinon';
import * as vscode from 'vscode';
import { InputUtils } from '../../../src/util/inputUtils';

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,189 @@
/*
* Copyright (c) 2022, salesforce.com, inc.
* All rights reserved.
* Licensed under the BSD 3-Clause license.
* For full license text, see LICENSE.txt file in the repo root or https://opensource.org/licenses/BSD-3-Clause
*/

// Imports for testing
import { expect } from 'chai';
import { createSandbox, SinonSandbox, SinonSpy } from 'sinon';
import * as vscode from 'vscode';
import { checkForExpiredOrgs } from '../../../src/util';

// Imports from the target source file
import { Aliases } from '@salesforce/core';
import { channelService } from '../../../src/channels';
import { OrgList } from '../../../src/orgPicker';

describe('orgUtil tests', () => {
let sb: SinonSandbox;

beforeEach(() => {
sb = createSandbox();
});

afterEach(async () => {
sb.restore();
});

describe('checkForExpiredOrgs tests', () => {
let today: Date;
let showWarningMessageSpy: SinonSpy;
let appendLineSpy: SinonSpy;
let showChannelOutputSpy: SinonSpy;

beforeEach(() => {
today = new Date();
showWarningMessageSpy = sb.spy(vscode.window, 'showWarningMessage');
appendLineSpy = sb.spy(channelService, 'appendLine');
showChannelOutputSpy = sb.spy(channelService, 'showChannelOutput');
});

afterEach(async () => {
showWarningMessageSpy.restore();
appendLineSpy.restore();
showChannelOutputSpy.restore();
});

it('should not display a notification when no orgs are present', async () => {
const getAuthInfoObjectsStub = sb
.stub(OrgList.prototype, 'getAuthInfoObjects')
.resolves([]);

await checkForExpiredOrgs();

expect(showWarningMessageSpy.called).to.equal(false);
expect(appendLineSpy.called).to.equal(false);
expect(showChannelOutputSpy.called).to.equal(false);

getAuthInfoObjectsStub.restore();
});

it('should not display a notification when dev hubs are present', async () => {
const getAuthInfoObjectsStub = sb
.stub(OrgList.prototype, 'getAuthInfoObjects')
.resolves([
{
isDevHub: true
},
{
isDevHub: false,
expirationDate: undefined
}
]);

await checkForExpiredOrgs();

expect(showWarningMessageSpy.called).to.equal(false);
expect(appendLineSpy.called).to.equal(false);
expect(showChannelOutputSpy.called).to.equal(false);

getAuthInfoObjectsStub.restore();
});

it('should not display a notification when the scratch org has already expired', async () => {
const getAuthInfoObjectsStub = sb
.stub(OrgList.prototype, 'getAuthInfoObjects')
.resolves([
{
isDevHub: false,
expirationDate: `${today.getFullYear()}-${today.getMonth() + 1}-${today.getDate() - 1}`
}
]);

await checkForExpiredOrgs();

expect(showWarningMessageSpy.called).to.equal(false);
expect(appendLineSpy.called).to.equal(false);
expect(showChannelOutputSpy.called).to.equal(false);

getAuthInfoObjectsStub.restore();
});

it('should display a notification when the scratch org is about to expire', async () => {
const getAuthInfoObjectsStub = sb
.stub(OrgList.prototype, 'getAuthInfoObjects')
.resolves([
{
isDevHub: false,
expirationDate: `${today.getFullYear()}-${today.getMonth() + 1}-${today.getDate() + 3}`,
username: 'foo'
}
]);
const getDefaultOptionsStub = sb
.stub(Aliases, 'getDefaultOptions')
.returns({
defaultGroup: 'orgs',
filename: 'alias.json',
isGlobal: true,
isState: true
});
const orgName = 'dreamhouse-org';
const createStub = sb
.stub(Aliases, 'create')
.resolves({
getKeysByValue: () => {
return orgName;
}
});

await checkForExpiredOrgs();

expect(showWarningMessageSpy.called).to.equal(true);
expect(appendLineSpy.called).to.equal(true);
expect(appendLineSpy.args[0][0]).to.contain(orgName);
expect(showChannelOutputSpy.called).to.equal(true);

getAuthInfoObjectsStub.restore();
getDefaultOptionsStub.restore();
createStub.restore();
});

it('should display multiple orgs in the output when there are several scratch orgs about to expire', async () => {
const getAuthInfoObjectsStub = sb
.stub(OrgList.prototype, 'getAuthInfoObjects')
.resolves([
{
isDevHub: false,
expirationDate: `${today.getFullYear()}-${today.getMonth() + 1}-${today.getDate() + 3}`,
username: 'foo'
},
{
isDevHub: false,
expirationDate: `${today.getFullYear()}-${today.getMonth() + 1}-${today.getDate() + 4}`,
username: 'bar'
}
]);
const getDefaultOptionsStub = sb
.stub(Aliases, 'getDefaultOptions')
.returns({
defaultGroup: 'orgs',
filename: 'alias.json',
isGlobal: true,
isState: true
});
const orgName1 = 'dreamhouse-org';
const orgName2 = 'ebikes-lwc';
const createStub = sb
.stub(Aliases, 'create')
.resolves({
getKeysByValue: (key: string) => {
return (key === 'foo') ? orgName1 : orgName2;
}
});

await checkForExpiredOrgs();

expect(showWarningMessageSpy.called).to.equal(true);
expect(appendLineSpy.called).to.equal(true);
expect(appendLineSpy.args[0][0]).to.contain(orgName1);
expect(appendLineSpy.args[0][0]).to.contain(orgName2);
expect(showChannelOutputSpy.called).to.equal(true);

getAuthInfoObjectsStub.restore();
getDefaultOptionsStub.restore();
createStub.restore();
});
});
});