Skip to content

Commit

Permalink
fix: validate tests against package directories
Browse files Browse the repository at this point in the history
  • Loading branch information
mcarvin8 committed Apr 5, 2024
1 parent f155b62 commit 5b9b94e
Show file tree
Hide file tree
Showing 7 changed files with 155 additions and 27 deletions.
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,8 @@ testclasses=$(<runTests.txt)
sf project deploy start -x manifest/package.xml -l RunSpecifiedTests -t $testclasses
```

_NOTE:_ The test classes will only be added to the output if they are found in one of your package directories as listed in the `sfdx-project.json`. If the test class name was not found in any package directory, a warning will be printed to the terminal. The plugin will not fail if no test classes are included in the final output. The output and text file will simply be empty if no delta test classes were found in any commit message or no test classes were validated against a package directory.

## Why another plugin to determine delta tests?

The [SFDX Git Delta ](https://github.com/scolladon/sfdx-git-delta) is an amazing tool that generates packages and directories for delta deployments. It could also be used to generate a comma-separated list of added/modified Apex classes, which could be used to run only the tests which were modified.
Expand Down
4 changes: 4 additions & 0 deletions messages/delta.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,3 +25,7 @@ The text file containing the Apex Tests regular expression to search for.
# flags.output.summary

The text file to save the delta test classes to.

# flags.sfdx-configuration.summary

Path to your project's Salesforce DX configuration file.
24 changes: 19 additions & 5 deletions src/commands/apex-tests-git-delta/delta.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,13 +20,13 @@ export default class ApexTestDelta extends SfCommand<TestDeltaResult> {
public static readonly examples = messages.getMessages('examples');

public static readonly flags = {
'to': Flags.string({
to: Flags.string({
char: 't',
summary: messages.getMessage('flags.to.summary'),
required: true,
default: TO_DEFAULT_VALUE,
}),
'from': Flags.string({
from: Flags.string({
char: 'f',
summary: messages.getMessage('flags.from.summary'),
required: true,
Expand All @@ -38,12 +38,19 @@ export default class ApexTestDelta extends SfCommand<TestDeltaResult> {
exists: true,
default: 'regex.txt',
}),
'output': Flags.file({
output: Flags.file({
summary: messages.getMessage('flags.output.summary'),
required: true,
exists: false,
default: 'runTests.txt',
}),
'sfdx-configuration': Flags.file({
summary: messages.getMessage('flags.sfdx-configuration.summary'),
char: 'c',
required: true,
exists: true,
default: 'sfdx-project.json',
}),
};

public async run(): Promise<TestDeltaResult> {
Expand All @@ -52,11 +59,18 @@ export default class ApexTestDelta extends SfCommand<TestDeltaResult> {
const fromGitRef = flags['from'];
const regExFile = flags['regular-expression'];
const output = flags['output'];
const sfdxConfigFile = flags['sfdx-configuration'];

const deltaTests = extractTestClasses(fromGitRef, toGitRef, regExFile);
const result = await extractTestClasses(fromGitRef, toGitRef, regExFile, sfdxConfigFile);
const deltaTests = result.validatedClasses;
const warnings = result.warnings;
this.log(deltaTests);
fs.writeFileSync(output, deltaTests);

if (warnings.length > 0) {
warnings.forEach((warning) => {
this.warn(warning);
});
}
return { tests: deltaTests };
}
}
45 changes: 31 additions & 14 deletions src/service/extractTestClasses.ts
Original file line number Diff line number Diff line change
@@ -1,24 +1,41 @@
'use strict'
'use strict';
import { retrieveCommitMessages } from './retrieveCommitMessages.js';
import { validateClassPaths } from './validateClassPaths.js';

export function extractTestClasses(fromRef: string, toRef: string, regex: string): string {
export async function extractTestClasses(
fromRef: string,
toRef: string,
regex: string,
sfdxConfigFile: string
): Promise<{ validatedClasses: string; warnings: string[] }> {
const testClasses: Set<string> = new Set();
const matchedMessages = retrieveCommitMessages(fromRef, toRef, regex);

matchedMessages.forEach((message: string) => {
// Split the commit message by commas or spaces
const classes = message.split(/,|\s/);
// Split the commit message by commas or spaces
const classes = message.split(/,|\s/);

classes.forEach((testClass: string) => {
// Remove leading/trailing whitespaces and add non-empty strings to the set
const trimmedClass = testClass.trim();
if (trimmedClass !== '') {
testClasses.add(trimmedClass);
}
});
classes.forEach((testClass: string) => {
// Remove leading/trailing whitespaces and add non-empty strings to the set
const trimmedClass = testClass.trim();
if (trimmedClass !== '') {
testClasses.add(trimmedClass);
}
});
});

// Sort test classes alphabetically and then return a space-separated string
const sortedClasses = Array.from(testClasses).sort((a, b) => a.localeCompare(b));
return sortedClasses.join(' ');
const unvalidatedClasses: string[] = Array.from(testClasses);
let validatedClasses: string = '';
const result =
unvalidatedClasses.length > 0
? await validateClassPaths(unvalidatedClasses, sfdxConfigFile)
: { validatedClasses: new Set(), warnings: [] };
let sortedClasses: string[] = [];
if (result.validatedClasses.size > 0) {
sortedClasses = Array.from(result.validatedClasses) as string[];
sortedClasses = sortedClasses.sort((a, b) => a.localeCompare(b));
validatedClasses = sortedClasses.join(' ');
}

return { validatedClasses, warnings: result.warnings };
}
20 changes: 20 additions & 0 deletions src/service/getPackageDirectories.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
'use strict';
/* eslint-disable no-await-in-loop */

import * as fs from 'node:fs';
import * as promises from 'node:fs/promises';

interface SfdxProject {
packageDirectories: Array<{ path: string }>;
}

export async function getPackageDirectories(dxConfigFile: string): Promise<string[]> {
if (!fs.existsSync(dxConfigFile)) {
throw Error(`Salesforce DX Config File does not exist in this path: ${dxConfigFile}`);
}

const sfdxProjectRaw: string = await promises.readFile(dxConfigFile, 'utf-8');
const sfdxProject: SfdxProject = JSON.parse(sfdxProjectRaw) as SfdxProject;
const packageDirectories = sfdxProject.packageDirectories.map((directory) => directory.path);
return packageDirectories;
}
50 changes: 50 additions & 0 deletions src/service/validateClassPaths.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
'use strict';
/* eslint-disable no-await-in-loop */

import * as fs from 'node:fs';
import * as path from 'node:path';

import { getPackageDirectories } from './getPackageDirectories.js';

export async function validateClassPaths(
unvalidatedClasses: string[],
dxConfigFile: string
): Promise<{ validatedClasses: Set<string>; warnings: string[] }> {
const packageDirectories = await getPackageDirectories(dxConfigFile);
const warnings: string[] = [];

const validatedClasses: Set<string> = new Set();
for (const unvalidatedClass of unvalidatedClasses) {
let validated: boolean = false;
for (const directory of packageDirectories) {
const relativeFilePath = await searchRecursively(`${unvalidatedClass}.cls`, directory);
if (relativeFilePath !== undefined) {
validatedClasses.add(unvalidatedClass);
validated = true;
break;
}
}
if (!validated)
warnings.push(
`The class ${unvalidatedClass} was not found in any package directory and will not be added to the delta test classes.`
);
}
return { validatedClasses, warnings };
}

async function searchRecursively(fileName: string, dxDirectory: string): Promise<string | undefined> {
const files = await fs.promises.readdir(dxDirectory);
for (const file of files) {
const filePath = path.join(dxDirectory, file);
const stats = await fs.promises.stat(filePath);
if (stats.isDirectory()) {
const result = await searchRecursively(fileName, filePath);
if (result) {
return result;
}
} else if (file === fileName) {
return filePath;
}
}
return undefined;
}
37 changes: 29 additions & 8 deletions test/commands/delta/unit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,11 @@ import { stubSfCommandUx } from '@salesforce/sf-plugins-core';
import ApexTestDelta from '../../../src/commands/apex-tests-git-delta/delta.js';

// Utility function to create temporary Git commits
async function createTemporaryCommit(message: string, content: string): Promise<string> {
// Create a temporary file
const tempFilePath = 'temp.txt';
await fs.promises.writeFile(tempFilePath, content);
async function createTemporaryCommit(message: string, filePath: string, content: string): Promise<string> {
await fs.promises.writeFile(filePath, content);

// Stage the file
execSync('git add temp.txt');
execSync(`git add "${filePath}"`);

// Commit with the provided message
execSync(`git commit -m "${message}"`);
Expand All @@ -32,13 +30,24 @@ describe('return the delta tests between git commits', () => {
const regExFile: string = 'regex.txt';
const regExFileContents: string = '[Aa][Pp][Ee][Xx]::(.*?)::[Aa][Pp][Ee][Xx]';
const originalDir = process.cwd();
const sfdxConfigFile = 'sfdx-project.json';
const sfdxConfigFileContents = {
packageDirectories: [{ path: 'force-app', default: true }, { path: 'packaged' }],
namespace: '',
sfdcLoginUrl: 'https://login.salesforce.com',
sourceApiVersion: '58.0',
};
const sfdxConfigJsonString = JSON.stringify(sfdxConfigFileContents, null, 2);
const tempDir = fs.mkdtempSync('../git-temp-');

before(async () => {
process.chdir(tempDir);
fs.mkdirSync('force-app/main/default/classes', { recursive: true });
fs.mkdirSync('packaged/classes', { recursive: true });
execSync('git init', { cwd: tempDir });
execSync('git branch -m main');
fs.writeFileSync(regExFile, regExFileContents);
fs.writeFileSync(sfdxConfigFile, sfdxConfigJsonString);
let userName = '';
let userEmail = '';

Expand All @@ -53,9 +62,21 @@ describe('return the delta tests between git commits', () => {
execSync('git config --global user.name "CI Bot"');
execSync('git config --global user.email "90224411+mcarvin8@users.noreply.github.com"');
}
fromSha = await createTemporaryCommit('chore: initial commit with Apex::TestClass00::Apex', 'dummy 1');
await createTemporaryCommit('chore: initial commit with Apex::SandboxTest::Apex', 'dummy 11');
toSha = await createTemporaryCommit('chore: adding new tests Apex::TestClass3 TestClass4::Apex', 'dummy 2');
fromSha = await createTemporaryCommit(
'chore: initial commit with Apex::TestClass00::Apex',
'force-app/main/default/classes/SandboxTest.cls',
'dummy 1'
);
await createTemporaryCommit(
'chore: initial commit with Apex::SandboxTest::Apex',
'force-app/main/default/classes/TestClass3.cls',
'dummy 11'
);
toSha = await createTemporaryCommit(
'chore: adding new tests Apex::TestClass3 TestClass4::Apex',
'packaged/classes/TestClass4.cls',
'dummy 2'
);
});

beforeEach(() => {
Expand Down

0 comments on commit 5b9b94e

Please sign in to comment.