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

Validate Tests Against Package Directories and Switch RegEx from File to Flag #2

Merged
merged 8 commits into from
Apr 5, 2024
11 changes: 7 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ This plugin requires Git Bash to be installed in your environment.

The tests are determined by looking at all commit messages in the commit range and extracting them with a regular expression defined in a text file.

For example, if the user creates a file named `regex.txt` in their repository with the below regular expression, the plugin will extract all test classes that are found with this expression and return a space-separated string with unique test classes.
For example, if the user provides the below regular expression via the `--regular-expression` flag, the plugin will extract all test classes that are found with this expression and return a space-separated string with unique test classes.

```
[Aa][Pp][Ee][Xx]::(.*?)::[Aa][Pp][Ee][Xx]
Expand Down 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 All @@ -64,12 +66,13 @@ Recommend running this command in your project's root directory.

```
USAGE
$ sf apex-tests-git-delta delta -f <value> -t <value> -e <value> --output <value> [--json]
$ sf apex-tests-git-delta delta -f <value> -t <value> -e <value> -c <value> --output <value> [--json]

FLAGS
-f, --from=<value> Commit SHA from where the commit message log is done. This SHA's commit message will not be included in the results.
-t, --to=<value> [default: HEAD] Commit SHA to where the commit message log is done.
-e, --regular-expression=<value> [default: regex.txt] The text file containing the Apex Tests regular expression to search for.
-e, --regular-expression=<value> [default: '[Aa][Pp][Ee][Xx]::(.*?)::[Aa][Pp][Ee][Xx]'] The regular expression to use when parsing commit messages for Apex Tests.
-c, --sfdx-configuration=<value> [default: sfdx-project.json] Path to your project's Salesforce DX configuration file.
--output=<value> [default: runTests.txt] The text file to save the delta test classes to.

GLOBAL FLAGS
Expand All @@ -79,5 +82,5 @@ DESCRIPTION
Given 2 git commits, this plugin will parse all of the commit messages between this range and return the delta Apex test class string. This can be used to execute delta deployments.

EXAMPLES
$ sf apex-tests-git-delta delta --from "abcdef" --to "ghifb" --regular-expression "regex.txt" --output "runTests.txt"
$ sf apex-tests-git-delta delta --from "c7603c255" --to "HEAD" --regular-expression "[Aa][Pp][Ee][Xx]::(.*?)::[Aa][Pp][Ee][Xx]" --sfdx-configuration "sfdx-project.json" --output "runTests.txt"
```
8 changes: 6 additions & 2 deletions messages/delta.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ Given 2 git commits, this plugin will parse all of the commit messages between t

# examples

- `sf apex-tests-git-delta delta --from "abcdef" --to "ghifb" --regular-expression "regex.txt" --output "runTests.txt"`
- `sf apex-tests-git-delta delta --from "c7603c255" --to "HEAD" --regular-expression "[Aa][Pp][Ee][Xx]::(.*?)::[Aa][Pp][Ee][Xx]" --sfdx-configuration "sfdx-project.json" --output "runTests.txt"`

# flags.from.summary

Expand All @@ -20,7 +20,11 @@ Commit SHA to where the commit message log is done.

# flags.regular-expression.summary

The text file containing the Apex Tests regular expression to search for.
The regular expression to use when parsing commit messages for Apex Tests.

# flags.sfdx-configuration.summary

Path to your project's Salesforce DX configuration file.

# flags.output.summary

Expand Down
39 changes: 26 additions & 13 deletions src/commands/apex-tests-git-delta/delta.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,14 @@ import * as fs from 'node:fs';

import { SfCommand, Flags } from '@salesforce/sf-plugins-core';
import { Messages } from '@salesforce/core';
import { TO_DEFAULT_VALUE } from '../../constants/gitConstants.js';
import { extractTestClasses } from '../../service/extractTestClasses.js';

Messages.importMessagesDirectoryFromMetaUrl(import.meta.url);
const messages = Messages.loadMessages('apex-tests-git-delta', 'delta');

export type TestDeltaResult = {
tests: string;
warnings: string[];
};

export default class ApexTestDelta extends SfCommand<TestDeltaResult> {
Expand All @@ -20,30 +20,36 @@ 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,
default: 'HEAD',
}),
'from': Flags.string({
from: Flags.string({
char: 'f',
summary: messages.getMessage('flags.from.summary'),
required: true,
}),
'regular-expression': Flags.file({
'regular-expression': Flags.string({
char: 'e',
summary: messages.getMessage('flags.regular-expression.summary'),
required: true,
exists: true,
default: 'regex.txt',
default: '[Aa][Pp][Ee][Xx]::(.*?)::[Aa][Pp][Ee][Xx]',
}),
'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 +58,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);
this.log(deltaTests);
fs.writeFileSync(output, deltaTests);

return { tests: deltaTests };
const result = await extractTestClasses(fromGitRef, toGitRef, regExFile, sfdxConfigFile);
const tests = result.validatedClasses;
const warnings = result.warnings;
fs.writeFileSync(output, tests);
if (warnings.length > 0) {
warnings.forEach((warning) => {
this.warn(warning);
});
}
this.log(tests);
return { tests, warnings };
}
}
2 changes: 0 additions & 2 deletions src/constants/gitConstants.ts

This file was deleted.

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;
}
46 changes: 22 additions & 24 deletions src/service/retrieveCommitMessages.ts
Original file line number Diff line number Diff line change
@@ -1,31 +1,29 @@
'use strict'
'use strict';
import { execSync } from 'node:child_process';
import * as fs from 'node:fs';

export function retrieveCommitMessages(fromCommit: string, toCommit: string, regexFilePath: string): string[] {
const gitLogCommand = `git log --format=%s ${fromCommit}..${toCommit}`;
let commitMessages: string;
try {
commitMessages = execSync(gitLogCommand, { encoding: 'utf-8' });
} catch (err) {
throw Error('The git diff failed to run due to the above error.');
}
export function retrieveCommitMessages(fromCommit: string, toCommit: string, regexPattern: string): string[] {
const gitLogCommand = `git log --format=%s ${fromCommit}..${toCommit}`;
let commitMessages: string;
try {
commitMessages = execSync(gitLogCommand, { encoding: 'utf-8' });
} catch (err) {
throw Error('The git diff failed to run due to the above error.');
}

let regexPattern = '';
try {
regexPattern = fs.readFileSync(regexFilePath, 'utf-8').trim();
} catch (err) {
throw Error(`The regular expression was unable to be extracted from ${regexFilePath}`);
}
let regex: RegExp;
try {
regex = new RegExp(regexPattern, 'g');
} catch (err) {
throw Error(`The regular expression '${regexPattern}' is invalid.`);
}

const regex = new RegExp(regexPattern, 'g');
const matchedMessages: string[] = [];
let match;
while ((match = regex.exec(commitMessages)) !== null) {
if (match[1]) {
matchedMessages.push(match[1]);
}
const matchedMessages: string[] = [];
let match;
while ((match = regex.exec(commitMessages)) !== null) {
if (match[1]) {
matchedMessages.push(match[1]);
}
}

return matchedMessages;
return matchedMessages;
}
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 promises from 'node:fs/promises';
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 promises.readdir(dxDirectory);
for (const file of files) {
const filePath = path.join(dxDirectory, file);
const stats = await promises.stat(filePath);
if (stats.isDirectory()) {
const result = await searchRecursively(fileName, filePath);
if (result) {
return result;
}
} else if (file === fileName) {
return filePath;
}
}
return undefined;
}
19 changes: 19 additions & 0 deletions test/commands/delta/createTemporaryCommit.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
'use strict';

import * as promises from 'node:fs/promises';
import { execSync } from 'node:child_process';

export async function createTemporaryCommit(message: string, filePath: string, content: string): Promise<string> {
await promises.writeFile(filePath, content);

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

// Commit with the provided message
execSync(`git commit -m "${message}"`);

// Return the commit hash of the newly created commit
const commitHash = execSync('git rev-parse HEAD', { encoding: 'utf-8' }).trim();

return commitHash;
}
Loading