@salesforce/salesforcedx-vscode-test-tools
Version:
Test automation framework for Salesforce Extensions for VS Code
303 lines (265 loc) • 10.1 kB
text/typescript
/*
* Copyright (c) 2025, 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 os from 'os';
import { EnvironmentSettings } from '../environmentSettings';
import { attemptToFindOutputPanelText, clearOutputView } from '../ui-interaction/outputView';
import { clickFilePathOkButton, executeQuickPick, findQuickPickItem } from '../ui-interaction/commandPrompt';
import * as DurationKit from '@salesforce/kit';
import path from 'path';
import { PredicateWithTimeout } from '../testing/predicates';
import { By, WebElement } from 'vscode-extension-tester';
import { getBrowser, getWorkbench } from '../ui-interaction/workbench';
import { expect } from 'chai';
import { retryOperation, verifyNotificationWithRetry } from '../retryUtils';
import { clickButtonOnModalDialog } from '../ui-interaction';
/**
* Pauses execution for a specified duration
* @param duration - The amount of time to pause; defaults to 1 second
*/
export async function pause(duration: Duration = Duration.seconds(1)): Promise<void> {
const throttleFactor = EnvironmentSettings.getInstance().throttleFactor;
await sleep(duration.milliseconds * throttleFactor);
}
/**
* Logs a message to the console if the log level allows it
* @param message - The message to log
*/
export function log(message: string): void {
if (EnvironmentSettings.getInstance().logLevel !== 'silent') {
console.log(message);
}
}
/**
* Logs a debug message to the console if the log level is debug or trace
* @param message - The debug message to log
*/
export function debug(message: string): void {
if (EnvironmentSettings.getInstance().logLevel in ['debug', 'trace']) {
const timestamp = new Date().toISOString();
console.debug(`${timestamp}:${message}`);
}
}
/**
* Logs an error message to the console if the log level is error
* @param message - The error message to log
*/
export function error(message: string): void {
if (EnvironmentSettings.getInstance().logLevel === 'error') {
console.error(`Error: ${message}`);
}
}
/**
* Gets the username of the current OS user
* @returns The current OS username
*/
export function currentOsUserName(): string {
const userName =
os.userInfo().username ||
process.env.SUDO_USER ||
process.env.C9_USER ||
process.env.LOGNAME ||
process.env.USER ||
process.env.LNAME ||
process.env.USERNAME;
if (!userName) {
throw new Error('Unable to determine current OS username');
}
return userName;
}
/**
* Returns a transformed version of the current username with periods replaced by underscores
* Used to work around an issue where InputBox.setText() truncates strings with periods
* @returns The transformed username
*/
export function transformedUserName(): string {
debug('transformedUsername()');
return currentOsUserName().replace('.', '_');
}
/**
* @param type type of html tag we want to find
* @param attribute attribute that holds the given text
* @param labelText text of the element we want to find
* @param waitForClickable whether to wait until the element is clickable
* @param waitOptions options for waiting until the element is clickable
* @returns element that contains the given text
*/
export async function findElementByText(
type: string,
attribute: string,
labelText: string | undefined,
waitForClickable: boolean | undefined = false,
waitOptions?: {
timeout?: Duration;
interval?: Duration;
reverse?: boolean;
timeoutMsg?: string;
}
): Promise<WebElement> {
if (!labelText) {
throw new Error('labelText must be defined');
}
debug(`findElementByText //${type}[@${attribute}="${labelText}"]`);
const element = await getWorkbench().findElement(By.xpath(`//${type}[@${attribute}="${labelText}"]`));
if (!element) {
throw new Error(`Element with selector: "${type}[${attribute}=\"${labelText}\"]" not found}`);
}
if (waitForClickable) {
await getBrowser().wait(
async () => {
const isDisplayedAndEnabled = (await element.isDisplayed()) && (await element.isEnabled());
return waitOptions?.reverse ? !isDisplayedAndEnabled : isDisplayedAndEnabled;
},
waitOptions?.timeout?.milliseconds ?? Duration.seconds(5).milliseconds,
waitOptions?.timeoutMsg,
waitOptions?.interval?.milliseconds ?? Duration.milliseconds(500).milliseconds
);
}
return element;
}
export async function createCommand(
type: string,
name: string,
folder: string,
extension: string
): Promise<string | undefined> {
await clearOutputView();
const inputBox = await executeQuickPick(`SFDX: Create ${type}`, Duration.seconds(1));
// Set the name of the new component to name.
await retryOperation(async () => {
await inputBox.setText(name);
await inputBox.confirm();
await pause(Duration.seconds(1));
await clickButtonOnModalDialog('Overwrite', false);
await pause(Duration.seconds(1));
});
// Select the default directory (press Enter/Return).
await inputBox.confirm();
await verifyNotificationWithRetry(
new RegExp(`SFDX: Create ${type} successfully ran`),
Duration.minutes(10)
);
const outputPanelText = await attemptToFindOutputPanelText(`Salesforce CLI`, `Finished SFDX: Create ${type}`, 10);
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
expect(outputPanelText).not.to.be.undefined;
const typePath = path.join(`force-app`, `main`, `default`, folder, `${name}.${extension}`);
expect(outputPanelText).to.include(`create ${typePath}`);
const metadataPath = path.join(`force-app`, `main`, `default`, folder, `${name}.${extension}-meta.xml`);
expect(outputPanelText).to.include(`create ${metadataPath}`);
return outputPanelText;
}
/**
* Sets the specified org as the default org
* @param targetOrg - The name of the org to set as default
*/
export async function setDefaultOrg(targetOrg: string): Promise<void> {
const inputBox = await executeQuickPick('SFDX: Set a Default Org');
await findQuickPickItem(inputBox, targetOrg, false, true);
}
/**
* Type guard function to check if the argument is a Duration
* @param predicateOrWait - The value to check
* @returns True if the value is a Duration, false otherwise
*/
export function isDuration(predicateOrWait: PredicateWithTimeout | Duration): predicateOrWait is Duration {
return (predicateOrWait as Duration).milliseconds !== undefined;
}
export enum Unit {
MINUTES = DurationKit.Duration.Unit.MINUTES,
MILLISECONDS = DurationKit.Duration.Unit.MILLISECONDS,
SECONDS = DurationKit.Duration.Unit.SECONDS,
HOURS = DurationKit.Duration.Unit.HOURS,
DAYS = DurationKit.Duration.Unit.DAYS,
WEEKS = DurationKit.Duration.Unit.WEEKS
}
export class Duration extends DurationKit.Duration {
private scaleFactor: number;
constructor(quantity: number, unit: Unit, scaleFactor?: number) {
super(quantity, unit);
if (scaleFactor !== undefined) {
this.scaleFactor = scaleFactor;
} else {
this.scaleFactor = EnvironmentSettings.getInstance().throttleFactor;
}
}
public get minutes(): number {
return super.minutes * this.scaleFactor;
}
public get hours(): number {
return super.hours * this.scaleFactor;
}
public get milliseconds(): number {
return super.milliseconds * this.scaleFactor;
}
public get seconds(): number {
return super.seconds * this.scaleFactor;
}
public get days(): number {
return super.days * this.scaleFactor;
}
public get weeks(): number {
return super.weeks * this.scaleFactor;
}
public static ONE_MINUTE = Duration.minutes(1);
public static FIVE_MINUTES = Duration.minutes(5);
public static TEN_MINUTES = Duration.minutes(10);
// Static methods for creating new instances without specifying scaleFactor
public static milliseconds(quantity: number): Duration {
return new Duration(quantity, Unit.MILLISECONDS);
}
public static seconds(quantity: number): Duration {
return new Duration(quantity, Unit.SECONDS);
}
public static minutes(quantity: number): Duration {
return new Duration(quantity, Unit.MINUTES);
}
public static hours(quantity: number): Duration {
return new Duration(quantity, Unit.HOURS);
}
public static days(quantity: number): Duration {
return new Duration(quantity, Unit.DAYS);
}
public static weeks(quantity: number): Duration {
return new Duration(quantity, Unit.WEEKS);
}
}
/**
* Pauses execution for a specified number of milliseconds
* @param duration - The number of milliseconds to sleep
* @returns A promise that resolves after the specified duration
*/
export async function sleep(duration: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, duration));
}
/*
* VSCode will be working on the new workspace, and the previous one is closed.
*/
export async function openFolder(path: string) {
await retryOperation(async () => {
const prompt = await executeQuickPick('File: Open Folder...'); // use this cmd palette to open
await pause(Duration.seconds(2));
// Set the location of the project
await prompt.setText(path);
await pause(Duration.seconds(2));
const projectName = path.substring(path.lastIndexOf('/') + 1);
await prompt.selectQuickPick(projectName);
await clickFilePathOkButton();
await pause(Duration.seconds(2));
});
}
/**
* A definite alternative of getTextEditor to open a file in text editor
* @param path
*/
export async function openFile(path: string) {
const prompt = await executeQuickPick('File: Open File...'); // use this cmd palette to open
// Set the location of the project
await prompt.setText(path);
await pause(Duration.seconds(2));
const fileName = path.substring(path.lastIndexOf(process.platform === 'win32' ? '\\' : '/') + 1);
await prompt.selectQuickPick(fileName);
await pause(Duration.seconds(5)); // wait for the file to open
}