From ae45bd335ae33a68b142d7673aae08325767d423 Mon Sep 17 00:00:00 2001 From: FishyGeek Date: Thu, 13 Aug 2026 12:32:11 -0600 Subject: [PATCH 1/4] fix: correct retention prune, failure tracking, and Apex tests (#17) Prune now uses a Datetime bind and runs in a Queueable so cascade deletes cannot roll back test enqueue. Failures key on class+method, Skip is not counted, and tests assert the real product behavior. Co-authored-by: Cursor --- .../main/default/classes/TestRunCleanup.cls | 72 ++++ .../classes/TestRunCleanup.cls-meta.xml | 5 + .../default/classes/TestRunCleanupTest.cls | 57 +++ .../classes/TestRunCleanupTest.cls-meta.xml | 5 + .../main/default/classes/TestRunProcessor.cls | 75 +++- .../default/classes/TestRunProcessorTest.cls | 355 +++++++++++++----- .../main/default/classes/TestRunScheduler.cls | 9 +- .../default/classes/TestRunSchedulerTest.cls | 46 ++- .../fields/Failure_Audit__c.field-meta.xml | 35 +- .../fields/Is_Failure__c.field-meta.xml | 9 + .../fields/Outcome__c.field-meta.xml | 11 + .../fields/Test_Failures__c.field-meta.xml | 4 +- 12 files changed, 531 insertions(+), 152 deletions(-) create mode 100644 force-app/main/default/classes/TestRunCleanup.cls create mode 100644 force-app/main/default/classes/TestRunCleanup.cls-meta.xml create mode 100644 force-app/main/default/classes/TestRunCleanupTest.cls create mode 100644 force-app/main/default/classes/TestRunCleanupTest.cls-meta.xml create mode 100644 force-app/main/default/objects/Test_Run_Method_Result__c/fields/Is_Failure__c.field-meta.xml create mode 100644 force-app/main/default/objects/Test_Run_Method_Result__c/fields/Outcome__c.field-meta.xml diff --git a/force-app/main/default/classes/TestRunCleanup.cls b/force-app/main/default/classes/TestRunCleanup.cls new file mode 100644 index 0000000..00322c2 --- /dev/null +++ b/force-app/main/default/classes/TestRunCleanup.cls @@ -0,0 +1,72 @@ +/** + * Async cleanup for Test_Run__c records. + * + * Prune and stuck-run abandonment run in a separate Queueable so they do not + * share a transaction with ApexTestQueueItem enqueue/DML. Cascade deletes of + * Test_Run_Method_Result__c children count toward DML row limits and can roll + * back the new test run when cleanup runs inline with enqueue. + */ +public with sharing class TestRunCleanup implements Queueable { + private static final Integer PRUNE_BATCH_SIZE = 200; + private static final Integer PRUNE_DAYS = 30; + private static final Integer STUCK_DAYS = 2; + + /** + * Abandons stuck runs, then prunes processed runs older than 30 days. + */ + public void execute(QueueableContext context) { + abandonStuckRuns(); + pruneOldRuns(); + } + + /** + * Marks unprocessed runs older than two days as processed so they stop + * blocking TestRunProcessor and become eligible for 30-day prune. + */ + private void abandonStuckRuns() { + Datetime stuckCutoff = Datetime.now().addDays(-STUCK_DAYS); + List stuckRuns = [ + SELECT Id + FROM Test_Run__c + WHERE Processed__c = false + AND CreatedDate < :stuckCutoff + ]; + + if (stuckRuns.isEmpty()) { + return; + } + + for (Test_Run__c stuckRun : stuckRuns) { + stuckRun.Processed__c = true; + } + + update stuckRuns; + } + + /** + * Deletes processed Test_Run__c records older than 30 days in batches of + * at most 200 parents per DML to stay within cascade-delete row limits. + */ + private void pruneOldRuns() { + Datetime cutoff = Datetime.now().addDays(-PRUNE_DAYS); + List toDelete = [ + SELECT Id + FROM Test_Run__c + WHERE Processed__c = true + AND CreatedDate < :cutoff + LIMIT :PRUNE_BATCH_SIZE + ]; + + while (!toDelete.isEmpty()) { + delete toDelete; + + toDelete = [ + SELECT Id + FROM Test_Run__c + WHERE Processed__c = true + AND CreatedDate < :cutoff + LIMIT :PRUNE_BATCH_SIZE + ]; + } + } +} diff --git a/force-app/main/default/classes/TestRunCleanup.cls-meta.xml b/force-app/main/default/classes/TestRunCleanup.cls-meta.xml new file mode 100644 index 0000000..db9bf8c --- /dev/null +++ b/force-app/main/default/classes/TestRunCleanup.cls-meta.xml @@ -0,0 +1,5 @@ + + + 48.0 + Active + diff --git a/force-app/main/default/classes/TestRunCleanupTest.cls b/force-app/main/default/classes/TestRunCleanupTest.cls new file mode 100644 index 0000000..32e2625 --- /dev/null +++ b/force-app/main/default/classes/TestRunCleanupTest.cls @@ -0,0 +1,57 @@ +@isTest(SeeAllData=false) +private class TestRunCleanupTest { + @isTest + static void pruneDeletesOldProcessedRuns() { + Test_Run__c oldRun = new Test_Run__c( + Parent_Job_Ids__c = 'cleanupPruneOldParentId', + Processed__c = true + ); + Test_Run__c recentRun = new Test_Run__c( + Parent_Job_Ids__c = 'cleanupPruneRecentParentId', + Processed__c = true + ); + insert new List{ oldRun, recentRun }; + + Test.setCreatedDate(oldRun.Id, Date.today().addDays(-31)); + + new TestRunCleanup().execute(null); + + List remaining = [ + SELECT Parent_Job_Ids__c + FROM Test_Run__c + WHERE Id IN :new Set{ oldRun.Id, recentRun.Id } + ]; + System.assertEquals(1, remaining.size()); + System.assertEquals('cleanupPruneRecentParentId', remaining[0].Parent_Job_Ids__c); + } + + @isTest + static void abandonStuckMarksOldUnprocessedRuns() { + Test_Run__c stuckRun = new Test_Run__c( + Parent_Job_Ids__c = 'cleanupAbandonStuckParentId', + Processed__c = false + ); + Test_Run__c recentRun = new Test_Run__c( + Parent_Job_Ids__c = 'cleanupAbandonRecentParentId', + Processed__c = false + ); + insert new List{ stuckRun, recentRun }; + + Test.setCreatedDate(stuckRun.Id, Date.today().addDays(-3)); + + new TestRunCleanup().execute(null); + + stuckRun = [ + SELECT Processed__c + FROM Test_Run__c + WHERE Id = :stuckRun.Id + ]; + recentRun = [ + SELECT Processed__c + FROM Test_Run__c + WHERE Id = :recentRun.Id + ]; + System.assertEquals(true, stuckRun.Processed__c); + System.assertEquals(false, recentRun.Processed__c); + } +} diff --git a/force-app/main/default/classes/TestRunCleanupTest.cls-meta.xml b/force-app/main/default/classes/TestRunCleanupTest.cls-meta.xml new file mode 100644 index 0000000..db9bf8c --- /dev/null +++ b/force-app/main/default/classes/TestRunCleanupTest.cls-meta.xml @@ -0,0 +1,5 @@ + + + 48.0 + Active + diff --git a/force-app/main/default/classes/TestRunProcessor.cls b/force-app/main/default/classes/TestRunProcessor.cls index 125ad7a..7f82559 100644 --- a/force-app/main/default/classes/TestRunProcessor.cls +++ b/force-app/main/default/classes/TestRunProcessor.cls @@ -8,6 +8,9 @@ public with sharing class TestRunProcessor implements Schedulable { @testVisible private List testResults; + @testVisible + private Boolean forceIncompleteQueue = false; + public void execute(SchedulableContext SC) { List testRuns = [SELECT Id, Parent_Job_Ids__c FROM Test_Run__c WHERE Processed__c = false]; @@ -50,7 +53,7 @@ public with sharing class TestRunProcessor implements Schedulable { WHERE ParentJobId IN :parentJobIds.split(',') ]; - if (Test.isRunningTest()) { + if (Test.isRunningTest() && !forceIncompleteQueue) { queueItems.add( (ApexTestQueueItem) JSON.deserialize( '{"ApexClass.Name":"TestRunProcessorTest","Status":"Completed"}', @@ -75,17 +78,25 @@ public with sharing class TestRunProcessor implements Schedulable { } /** - * Process each unit test's method result and created a related Automated_Test_Job_Results__c + * Process each unit test's method result and create a related Test_Run_Method_Result__c * record. */ private void processTestResults(Test_Run__c testRun, List apexTestResults) { + if (apexTestResults == null) { + apexTestResults = new List(); + } + List results = new List(); for (ApexTestResult apexTestResult : apexTestResults) { + Boolean isPass = apexTestResult.Outcome == 'Pass'; + Boolean isFailure = apexTestResult.Outcome == 'Fail' || apexTestResult.Outcome == 'CompileFail'; results.add( new Test_Run_Method_Result__c( Message__c = apexTestResult.message, Method_Name__c = apexTestResult.MethodName, - Method_Pass__c = apexTestResult.Outcome == 'Pass' ? true : false, + Method_Pass__c = isPass, + Is_Failure__c = isFailure, + Outcome__c = apexTestResult.Outcome, Name = apexTestResult.ApexClass.Name, Stack_Trace__c = apexTestResult.stackTrace, Run_Time__c = apexTestResult.RunTime, @@ -95,44 +106,82 @@ public with sharing class TestRunProcessor implements Schedulable { } List failedTests = new List(); for (Test_Run_Method_Result__c r : results) { - if (!r.Method_Pass__c) { + if (r.Is_Failure__c) { failedTests.add(r); } } processPassFailDates(testRun, failedTests); + testRun.Failure_Summary__c = buildFailureSummary(failedTests); update testRun; insert results; } + private String buildFailureSummary(List failedTests) { + if (failedTests.isEmpty()) { + return ''; + } + + List lines = new List(); + for (Test_Run_Method_Result__c failure : failedTests) { + String className = failure.Name != null ? failure.Name : ''; + String methodName = failure.Method_Name__c != null ? failure.Method_Name__c : ''; + String key = className + '.' + methodName; + String message = failure.Message__c != null ? failure.Message__c : ''; + Integer newlineIndex = message.indexOf('\n'); + String firstLine = newlineIndex >= 0 ? message.substring(0, newlineIndex) : message; + lines.add(key + ': ' + firstLine); + } + + String summary = String.join(lines, '\n'); + if (summary.length() > 2000) { + summary = summary.substring(0, 2000); + } + return summary; + } + + private String compositeMethodKey(Test_Run_Method_Result__c r) { + String className = r.Name != null ? r.Name : ''; + String methodName = r.Method_Name__c != null ? r.Method_Name__c : ''; + return className + '.' + methodName; + } + private void processPassFailDates(Test_Run__c testRun, List failedTests) { Map> previousResultsMap = new Map>(); + Set methodNames = new Set(); + Set classNames = new Set(); for (Test_Run_Method_Result__c r : failedTests) { - previousResultsMap.put(r.Method_Name__c, new List()); + String key = compositeMethodKey(r); + previousResultsMap.put(key, new List()); + methodNames.add(r.Method_Name__c != null ? r.Method_Name__c : ''); + classNames.add(r.Name != null ? r.Name : ''); } + List previousResults = [ - SELECT Id, Method_Name__c, Method_Pass__c, CreatedDate + SELECT Id, Name, Method_Name__c, Method_Pass__c, Is_Failure__c, CreatedDate FROM Test_Run_Method_Result__c - WHERE Method_Name__c IN :previousResultsMap.keySet() + WHERE Method_Name__c IN :methodNames AND Name IN :classNames ORDER BY CreatedDate DESC ]; for (Test_Run_Method_Result__c f : previousResults) { - if (previousResultsMap.containsKey(f.Method_Name__c)) { - List tempList = previousResultsMap.get(f.Method_Name__c); + String key = compositeMethodKey(f); + if (previousResultsMap.containsKey(key)) { + List tempList = previousResultsMap.get(key); tempList.add(f); } } Integer newTestFailures = 0; for (Test_Run_Method_Result__c failure : failedTests) { + String failureKey = compositeMethodKey(failure); failure.First_Failure__c = Datetime.now(); Boolean newFailure = true; - for (Test_Run_Method_Result__c previousResult : previousResultsMap.get(failure.Method_Name__c)) { - if (failure.Method_Name__c == previousResult.Method_Name__c && !previousResult.Method_Pass__c) { + for (Test_Run_Method_Result__c previousResult : previousResultsMap.get(failureKey)) { + if (previousResult.Is_Failure__c) { newFailure = false; failure.First_Failure__c = previousResult.CreatedDate; } - if (failure.Method_Name__c == previousResult.Method_Name__c && previousResult.Method_Pass__c) { + if (previousResult.Method_Pass__c) { failure.Last_Success__c = previousResult.CreatedDate; break; } @@ -144,4 +193,4 @@ public with sharing class TestRunProcessor implements Schedulable { } testRun.New_Failures__c = newTestFailures; } -} \ No newline at end of file +} diff --git a/force-app/main/default/classes/TestRunProcessorTest.cls b/force-app/main/default/classes/TestRunProcessorTest.cls index 67f0e2d..9c6232a 100644 --- a/force-app/main/default/classes/TestRunProcessorTest.cls +++ b/force-app/main/default/classes/TestRunProcessorTest.cls @@ -1,129 +1,278 @@ -/** - * Unit tests for Run Processor - */ -@isTest +@isTest(SeeAllData=false) private class TestRunProcessorTest { @isTest - public static void testQueuer() { - TestRunProcessor testProcessor = new TestRunProcessor(); - testProcessor.testResults = generateApexTestResults(); - - Test.StartTest(); - System.schedule('Automated Test Job Queuer [UNIT TESTING]', '0 0 23 * * ?', testProcessor); - testProcessor.execute(null); - List processorResults = [ - SELECT Method_Name__c, Method_Pass__c + static void passingAndFailingMethodsProcessed() { + String parentJobId = 'processorPassFailParentId'; + Test_Run__c testRun = new Test_Run__c(Parent_Job_Ids__c = parentJobId, Processed__c = false); + insert testRun; + + TestRunProcessor processor = new TestRunProcessor(); + processor.testResults = new List{ + buildResult('Pass', 'passingMethod', 'All good', parentJobId, 'PassingClass'), + buildResult('Fail', 'failingMethod', 'Assertion failed', parentJobId, 'FailingClass') + }; + + Test.startTest(); + processor.execute(null); + Test.stopTest(); + + testRun = [ + SELECT Processed__c, Failure_Summary__c, New_Failures__c + FROM Test_Run__c + WHERE Id = :testRun.Id + ]; + System.assertEquals(true, testRun.Processed__c); + System.assertEquals(1, testRun.New_Failures__c); + System.assert(testRun.Failure_Summary__c.contains('FailingClass.failingMethod')); + + List methodResults = [ + SELECT Method_Name__c, Method_Pass__c, Is_Failure__c, Name FROM Test_Run_Method_Result__c - WHERE Name = 'Generic' + WHERE Test_Run__c = :testRun.Id ]; - System.assertEquals('Test1', processorResults[0].Method_Name__c); - System.assertEquals(true, processorResults[0].Method_Pass__c); + System.assertEquals(2, methodResults.size()); - Test.stopTest(); + Map resultsByClass = new Map(); + for (Test_Run_Method_Result__c result : methodResults) { + resultsByClass.put(result.Name, result); + } + + Test_Run_Method_Result__c passing = resultsByClass.get('PassingClass'); + System.assertEquals('passingMethod', passing.Method_Name__c); + System.assertEquals(true, passing.Method_Pass__c); + System.assertEquals(false, passing.Is_Failure__c); + + Test_Run_Method_Result__c failing = resultsByClass.get('FailingClass'); + System.assertEquals('failingMethod', failing.Method_Name__c); + System.assertEquals(false, failing.Method_Pass__c); + System.assertEquals(true, failing.Is_Failure__c); } - /** - * Tests processPassFailDates() uses fake test "PassFailProccess" named - * in Test Generator classes below. - */ + @isTest - public static void PassFailProcessorTest() { - List pastTestResults = generatePastTestRunResults(); - insert pastTestResults; + static void newFailureAfterPriorPass() { + String parentJobId = 'processorNewFailureParentId'; + Test_Run__c priorRun = new Test_Run__c( + Parent_Job_Ids__c = 'processorNewFailurePriorParentId', + Processed__c = true + ); + insert priorRun; + insert new Test_Run_Method_Result__c( + Message__c = 'prior pass', + Method_Name__c = 'Test2', + Method_Pass__c = true, + Is_Failure__c = false, + Name = 'PassFailProcess', + Test_Run__c = priorRun.Id + ); - //wait a second - Integer start = System.Now().second(); - while (System.Now().second() < start + 1) { - } + Test_Run__c testRun = new Test_Run__c(Parent_Job_Ids__c = parentJobId, Processed__c = false); + insert testRun; + + TestRunProcessor processor = new TestRunProcessor(); + processor.testResults = new List{ + buildResult('Fail', 'Test2', 'now failing', parentJobId, 'PassFailProcess') + }; + + Test.startTest(); + processor.execute(null); + Test.stopTest(); - TestRunProcessor testProcessor = new TestRunProcessor(); - testProcessor.testResults = generateApexTestResults(); - - Test.StartTest(); - testProcessor.execute(null); - List processorResults = [ - SELECT - Method_Name__c, - Method_Pass__c, - Test_Run__c, - CreatedDate, - First_Failure__c, - Last_Success__c, - Failure_Audit__c + List results = [ + SELECT New_Failure__c, Last_Success__c, First_Failure__c FROM Test_Run_Method_Result__c - WHERE Name = 'PassFailProccess' - ORDER BY CreatedDate DESC + WHERE Test_Run__c = :testRun.Id + AND Name = 'PassFailProcess' + AND Method_Name__c = 'Test2' ]; - for (Test_Run_Method_Result__c tr : processorResults) { - System.debug('Test: ' + tr.Method_Name__c); - System.debug('Pass Fail: ' + tr.Method_Pass__c); - System.debug('Date: ' + tr.CreatedDate); - System.debug('Audit: ' + tr.Failure_Audit__c); - System.debug('First Fail: ' + tr.First_Failure__c); - } + System.assertEquals(1, results.size()); + System.assertEquals(true, results[0].New_Failure__c); + System.assertNotEquals(null, results[0].Last_Success__c); + System.assertNotEquals(null, results[0].First_Failure__c); + } - System.assertEquals('Test2', processorResults[0].Method_Name__c); - System.assertNotEquals(null, processorResults[0].First_Failure__c); - System.assertEquals('Method Passed', processorResults[2].Failure_Audit__c); + @isTest + static void recurringFailureCopiesFirstFailureDate() { + String parentJobId = 'processorRecurringParentId'; + Test_Run__c priorRun = new Test_Run__c( + Parent_Job_Ids__c = 'processorRecurringPriorParentId', + Processed__c = true + ); + insert priorRun; + Test_Run_Method_Result__c priorFailure = new Test_Run_Method_Result__c( + Message__c = 'first fail', + Method_Name__c = 'recurringMethod', + Method_Pass__c = false, + Is_Failure__c = true, + Name = 'RecurringClass', + Test_Run__c = priorRun.Id + ); + insert priorFailure; + Datetime priorCreatedDate = [ + SELECT CreatedDate + FROM Test_Run_Method_Result__c + WHERE Id = :priorFailure.Id + ] + .CreatedDate; + + Test_Run__c testRun = new Test_Run__c(Parent_Job_Ids__c = parentJobId, Processed__c = false); + insert testRun; + + TestRunProcessor processor = new TestRunProcessor(); + processor.testResults = new List{ + buildResult('Fail', 'recurringMethod', 'still failing', parentJobId, 'RecurringClass') + }; + Test.startTest(); + processor.execute(null); Test.stopTest(); + + List results = [ + SELECT New_Failure__c, First_Failure__c + FROM Test_Run_Method_Result__c + WHERE Test_Run__c = :testRun.Id + AND Name = 'RecurringClass' + AND Method_Name__c = 'recurringMethod' + ]; + System.assertEquals(1, results.size()); + System.assertEquals(false, results[0].New_Failure__c); + System.assertEquals(priorCreatedDate, results[0].First_Failure__c); } - /** - * Generates a List of ApexTestResult and returns them. - * Also inserts a List of Test_Run__c records to mock seperate runs and for id purposes - */ - private static List generateApexTestResults() { - List testRun = new List{ - new Test_Run__c(Parent_Job_Ids__c = '7073t00005OHeNPCA1', Processed__c = false) - }; + + @isTest + static void skipIsNotCountedAsFailure() { + String parentJobId = 'processorSkipParentId'; + Test_Run__c testRun = new Test_Run__c(Parent_Job_Ids__c = parentJobId, Processed__c = false); insert testRun; - List testResults = new List{ - new ApexTestResult( - Outcome = 'Pass', - MethodName = 'Test1', - Message = 'asdf', - StackTrace = 'asdf', - AsyncApexJobId = testRun[0].Parent_Job_Ids__c, - ApexClass = new ApexClass(Name = 'Generic') - ), - new ApexTestResult( - Outcome = 'Fail', - MethodName = 'Test2', - Message = 'asdf', - StackTrace = 'asdf', - AsyncApexJobId = testRun[0].Parent_Job_Ids__c, - ApexClass = new ApexClass(Name = 'PassFailProccess') - ) + + TestRunProcessor processor = new TestRunProcessor(); + processor.testResults = new List{ + buildResult('Skip', 'skippedMethod', 'skipped', parentJobId, 'SkipClass') }; - return testResults; + + Test.startTest(); + processor.execute(null); + Test.stopTest(); + + testRun = [ + SELECT New_Failures__c, Failure_Summary__c + FROM Test_Run__c + WHERE Id = :testRun.Id + ]; + System.assertEquals(0, testRun.New_Failures__c); + System.assert(String.isBlank(testRun.Failure_Summary__c)); + + List results = [ + SELECT Method_Pass__c, Is_Failure__c, Outcome__c + FROM Test_Run_Method_Result__c + WHERE Test_Run__c = :testRun.Id + ]; + System.assertEquals(1, results.size()); + System.assertEquals(false, results[0].Method_Pass__c); + System.assertEquals(false, results[0].Is_Failure__c); + System.assertEquals('Skip', results[0].Outcome__c); } - /** - * Generates a List of Test_Run_Method_Restult__c and inserts them. - * Also inserts a List of Test_Run__c records to mock seperate runs and for id purposes - */ - private static List generatePastTestRunResults() { - List testRun = new List{ - new Test_Run__c(Parent_Job_Ids__c = '7073t00005OHeNPAA1', Processed__c = true), - new Test_Run__c(Parent_Job_Ids__c = '7073t00005OHbgxAAD', Processed__c = true) - }; + + @isTest + static void incompleteQueueLeavesRunUnprocessed() { + String parentJobId = 'processorIncompleteParentId'; + Test_Run__c testRun = new Test_Run__c(Parent_Job_Ids__c = parentJobId, Processed__c = false); insert testRun; - List testRunResults = new List{ + + TestRunProcessor processor = new TestRunProcessor(); + processor.forceIncompleteQueue = true; + processor.testResults = new List{ + buildResult('Fail', 'shouldNotProcess', 'blocked', parentJobId, 'BlockedClass') + }; + + Test.startTest(); + processor.execute(null); + Test.stopTest(); + + testRun = [ + SELECT Processed__c + FROM Test_Run__c + WHERE Id = :testRun.Id + ]; + System.assertEquals(false, testRun.Processed__c); + + List results = [ + SELECT Id + FROM Test_Run_Method_Result__c + WHERE Test_Run__c = :testRun.Id + ]; + System.assertEquals(0, results.size()); + } + + @isTest + static void historyDoesNotCollideAcrossClasses() { + String parentJobId = 'processorHistoryParentId'; + Test_Run__c priorRun = new Test_Run__c( + Parent_Job_Ids__c = 'processorHistoryPriorParentId', + Processed__c = true + ); + insert priorRun; + insert new List{ new Test_Run_Method_Result__c( - Message__c = 'asdf', - Method_Name__c = 'Test2', + Message__c = 'class a pass', + Method_Name__c = 'sameMethod', Method_Pass__c = true, - Name = 'PassFailProccess', - Stack_Trace__c = 'asdf', - Test_Run__c = testRun[0].Id + Is_Failure__c = false, + Name = 'ClassA', + Test_Run__c = priorRun.Id ), new Test_Run_Method_Result__c( - Message__c = 'asdf', - Method_Name__c = 'Test2', - Method_Pass__c = true, - Name = 'PassFailProccess', - Stack_Trace__c = 'asdf', - Test_Run__c = testRun[1].Id + Message__c = 'class b fail', + Method_Name__c = 'sameMethod', + Method_Pass__c = false, + Is_Failure__c = true, + Name = 'ClassB', + Test_Run__c = priorRun.Id ) }; - return testRunResults; + + Test_Run__c testRun = new Test_Run__c(Parent_Job_Ids__c = parentJobId, Processed__c = false); + insert testRun; + + TestRunProcessor processor = new TestRunProcessor(); + processor.testResults = new List{ + buildResult('Fail', 'sameMethod', 'class a now fails', parentJobId, 'ClassA'), + buildResult('Fail', 'sameMethod', 'class b still fails', parentJobId, 'ClassB') + }; + + Test.startTest(); + processor.execute(null); + Test.stopTest(); + + Map resultsByClass = new Map(); + for (Test_Run_Method_Result__c result : [ + SELECT Name, New_Failure__c, Last_Success__c + FROM Test_Run_Method_Result__c + WHERE Test_Run__c = :testRun.Id + AND Method_Name__c = 'sameMethod' + ]) { + resultsByClass.put(result.Name, result); + } + + System.assertEquals(2, resultsByClass.size()); + System.assertEquals(true, resultsByClass.get('ClassA').New_Failure__c); + System.assertNotEquals(null, resultsByClass.get('ClassA').Last_Success__c); + System.assertEquals(false, resultsByClass.get('ClassB').New_Failure__c); + System.assertEquals(null, resultsByClass.get('ClassB').Last_Success__c); + } + + private static ApexTestResult buildResult( + String outcome, + String methodName, + String message, + String parentJobId, + String className + ) { + return new ApexTestResult( + Outcome = outcome, + MethodName = methodName, + Message = message, + StackTrace = 'stack', + ApexClass = new ApexClass(Name = className) + ); } -} \ No newline at end of file +} diff --git a/force-app/main/default/classes/TestRunScheduler.cls b/force-app/main/default/classes/TestRunScheduler.cls index 380985f..b2ed915 100644 --- a/force-app/main/default/classes/TestRunScheduler.cls +++ b/force-app/main/default/classes/TestRunScheduler.cls @@ -17,10 +17,8 @@ public with sharing class TestRunScheduler implements Schedulable { if (testClasses.size() > 0) { List> queueItems = new List>(); List innerQueueItems = new List(); - Integer counter = 0; for (ApexClass testClass : testClasses) { - counter++; innerQueueItems.add(new ApexTestQueueItem(ApexClassId = testClass.Id)); if (innerQueueItems.size() == 200) { @@ -62,11 +60,6 @@ public with sharing class TestRunScheduler implements Schedulable { insert newRun; } - clearOldRuns(); - } - - public void clearOldRuns() { - // delete completed runs older than a month, will remove child Test_Run_Method_Result__c tuples as well - delete [SELECT Id FROM Test_Run__c WHERE Processed__c = true AND CreatedDate > LAST_N_DAYS:30]; + System.enqueueJob(new TestRunCleanup()); } } diff --git a/force-app/main/default/classes/TestRunSchedulerTest.cls b/force-app/main/default/classes/TestRunSchedulerTest.cls index 8f5390c..1591406 100644 --- a/force-app/main/default/classes/TestRunSchedulerTest.cls +++ b/force-app/main/default/classes/TestRunSchedulerTest.cls @@ -1,17 +1,41 @@ -/** - * - */ -@isTest +@isTest(SeeAllData=false) private class TestRunSchedulerTest { @isTest - public static void testRunTest() { - List classes = [SELECT Id, Name, Body FROM ApexClass WHERE Name = 'TestRunSchedulerTest']; + static void testRunCreatesRecord() { + List classes = [ + SELECT Id + FROM ApexClass + WHERE Name = 'TestRunSchedulerTest' + ]; + Test.setFixedSearchResults(new List{ classes[0].Id }); - // https://developer.salesforce.com/docs/atlas.en-us.apexcode.meta/apexcode/apex_testing_SOSL.htm - Test.setFixedSearchResults(new List{ classes[0].Id }); // ApexClass result type Id for search queries + Test.startTest(); + new TestRunScheduler().execute(null); + Test.stopTest(); + + List runs = [ + SELECT Parent_Job_Ids__c, Processed__c + FROM Test_Run__c + WHERE Parent_Job_Ids__c = 'testingParentJobId' + ]; + System.assertEquals(1, runs.size()); + System.assertEquals('testingParentJobId', runs[0].Parent_Job_Ids__c); + System.assertEquals(false, runs[0].Processed__c); + } - Test.StartTest(); - System.schedule('Automated Test Job [UNIT TESTING]', '0 0 23 * * ?', new TestRunScheduler()); + @isTest + static void testEmptySoslStillEnqueuesCleanup() { + Test.setFixedSearchResults(new List()); + + Test.startTest(); + new TestRunScheduler().execute(null); Test.stopTest(); + + List runs = [ + SELECT Id + FROM Test_Run__c + WHERE Parent_Job_Ids__c = 'testingParentJobId' + ]; + System.assertEquals(0, runs.size()); } -} \ No newline at end of file +} diff --git a/force-app/main/default/objects/Test_Run_Method_Result__c/fields/Failure_Audit__c.field-meta.xml b/force-app/main/default/objects/Test_Run_Method_Result__c/fields/Failure_Audit__c.field-meta.xml index 6487d9f..12b74c8 100644 --- a/force-app/main/default/objects/Test_Run_Method_Result__c/fields/Failure_Audit__c.field-meta.xml +++ b/force-app/main/default/objects/Test_Run_Method_Result__c/fields/Failure_Audit__c.field-meta.xml @@ -1,15 +1,20 @@ - - - Failure_Audit__c - false - IF(Method_Pass__c == false, -"SELECT Id, Action, CreatedBy.Name, CreatedDate, Display, Section FROM SetupAuditTrail WHERE CreatedDate > " & SUBSTITUTE(TEXT(Last_Success__c), ' ', 'T') & " AND CreatedDate < " & SUBSTITUTE(TEXT(First_Failure__c), ' ', 'T'), -"Method Passed" -) - BlankAsZero - - false - false - Text - false - + + + Failure_Audit__c + false + IF( +Method_Pass__c, +"Method Passed", +IF( +ISBLANK(Last_Success__c), +"No prior success recorded; query SetupAuditTrail before " & SUBSTITUTE(TEXT(First_Failure__c), ' ', 'T'), +"SELECT Id, Action, CreatedBy.Name, CreatedDate, Display, Section FROM SetupAuditTrail WHERE CreatedDate > " & SUBSTITUTE(TEXT(Last_Success__c), ' ', 'T') & " AND CreatedDate < " & SUBSTITUTE(TEXT(First_Failure__c), ' ', 'T') +) +) + BlankAsBlank + + false + false + Text + false + diff --git a/force-app/main/default/objects/Test_Run_Method_Result__c/fields/Is_Failure__c.field-meta.xml b/force-app/main/default/objects/Test_Run_Method_Result__c/fields/Is_Failure__c.field-meta.xml new file mode 100644 index 0000000..488c80d --- /dev/null +++ b/force-app/main/default/objects/Test_Run_Method_Result__c/fields/Is_Failure__c.field-meta.xml @@ -0,0 +1,9 @@ + + + Is_Failure__c + false + false + + false + Checkbox + diff --git a/force-app/main/default/objects/Test_Run_Method_Result__c/fields/Outcome__c.field-meta.xml b/force-app/main/default/objects/Test_Run_Method_Result__c/fields/Outcome__c.field-meta.xml new file mode 100644 index 0000000..0e52b26 --- /dev/null +++ b/force-app/main/default/objects/Test_Run_Method_Result__c/fields/Outcome__c.field-meta.xml @@ -0,0 +1,11 @@ + + + Outcome__c + false + + 40 + false + false + Text + false + diff --git a/force-app/main/default/objects/Test_Run__c/fields/Test_Failures__c.field-meta.xml b/force-app/main/default/objects/Test_Run__c/fields/Test_Failures__c.field-meta.xml index a36c9b3..f0bcc90 100644 --- a/force-app/main/default/objects/Test_Run__c/fields/Test_Failures__c.field-meta.xml +++ b/force-app/main/default/objects/Test_Run__c/fields/Test_Failures__c.field-meta.xml @@ -4,9 +4,9 @@ false - Test_Run_Method_Result__c.Method_Pass__c + Test_Run_Method_Result__c.Is_Failure__c equals - False + True Test_Run_Method_Result__c.Test_Run__c count From ae95d513a8a6952d6ed82b854a3e509b17536a32 Mon Sep 17 00:00:00 2001 From: FishyGeek Date: Thu, 13 Aug 2026 12:39:28 -0600 Subject: [PATCH 2/4] feat: improve discovery, run status, and packaged UX (#18) Exclude namespaced classes from SOSL, store parent job IDs as long text, and surface Queued/Processed/Failed/Abandoned on Test_Run__c. Tabs, list views, layouts, and permission-set FLS make the package usable after install. Co-authored-by: Cursor --- .../main/default/classes/TestRunCleanup.cls | 1 + .../default/classes/TestRunCleanupTest.cls | 5 +- .../main/default/classes/TestRunProcessor.cls | 90 ++++++++++++--- .../default/classes/TestRunProcessorTest.cls | 30 ++++- .../main/default/classes/TestRunScheduler.cls | 106 ++++++++++++------ .../default/classes/TestRunSchedulerTest.cls | 28 +++-- ...ted Test Job Result Layout.layout-meta.xml | 16 +++ ...est_Run__c-Test Run Layout.layout-meta.xml | 26 ++++- .../fields/New_Failure__c.field-meta.xml | 2 +- .../Failed_Methods.listView-meta.xml | 15 +++ .../fields/Error_Message__c.field-meta.xml | 11 ++ .../fields/Parent_Job_Ids__c.field-meta.xml | 10 +- .../fields/Status__c.field-meta.xml | 41 +++++++ .../Unprocessed_Runs.listView-meta.xml | 14 +++ .../Test_Runner.permissionset-meta.xml | 68 +++++++++++ .../Test_Run_Method_Result__c.tab-meta.xml | 5 + .../default/tabs/Test_Run__c.tab-meta.xml | 5 + 17 files changed, 400 insertions(+), 73 deletions(-) create mode 100644 force-app/main/default/objects/Test_Run_Method_Result__c/listViews/Failed_Methods.listView-meta.xml create mode 100644 force-app/main/default/objects/Test_Run__c/fields/Error_Message__c.field-meta.xml create mode 100644 force-app/main/default/objects/Test_Run__c/fields/Status__c.field-meta.xml create mode 100644 force-app/main/default/objects/Test_Run__c/listViews/Unprocessed_Runs.listView-meta.xml create mode 100644 force-app/main/default/tabs/Test_Run_Method_Result__c.tab-meta.xml create mode 100644 force-app/main/default/tabs/Test_Run__c.tab-meta.xml diff --git a/force-app/main/default/classes/TestRunCleanup.cls b/force-app/main/default/classes/TestRunCleanup.cls index 00322c2..53daccc 100644 --- a/force-app/main/default/classes/TestRunCleanup.cls +++ b/force-app/main/default/classes/TestRunCleanup.cls @@ -38,6 +38,7 @@ public with sharing class TestRunCleanup implements Queueable { for (Test_Run__c stuckRun : stuckRuns) { stuckRun.Processed__c = true; + stuckRun.Status__c = 'Abandoned'; } update stuckRuns; diff --git a/force-app/main/default/classes/TestRunCleanupTest.cls b/force-app/main/default/classes/TestRunCleanupTest.cls index 32e2625..7a67b7f 100644 --- a/force-app/main/default/classes/TestRunCleanupTest.cls +++ b/force-app/main/default/classes/TestRunCleanupTest.cls @@ -42,16 +42,17 @@ private class TestRunCleanupTest { new TestRunCleanup().execute(null); stuckRun = [ - SELECT Processed__c + SELECT Processed__c, Status__c FROM Test_Run__c WHERE Id = :stuckRun.Id ]; recentRun = [ - SELECT Processed__c + SELECT Processed__c, Status__c FROM Test_Run__c WHERE Id = :recentRun.Id ]; System.assertEquals(true, stuckRun.Processed__c); + System.assertEquals('Abandoned', stuckRun.Status__c); System.assertEquals(false, recentRun.Processed__c); } } diff --git a/force-app/main/default/classes/TestRunProcessor.cls b/force-app/main/default/classes/TestRunProcessor.cls index 7f82559..65119e2 100644 --- a/force-app/main/default/classes/TestRunProcessor.cls +++ b/force-app/main/default/classes/TestRunProcessor.cls @@ -5,34 +5,91 @@ * parse the results. */ public with sharing class TestRunProcessor implements Schedulable { + private static final Integer ERROR_MESSAGE_MAX_LENGTH = 32768; + private static final Integer VANISHED_QUEUE_TIMEOUT_HOURS = 6; + private static final String ABANDONED_QUEUE_MESSAGE = 'No ApexTestQueueItem records found after timeout'; + @testVisible private List testResults; @testVisible private Boolean forceIncompleteQueue = false; + @testVisible + private Boolean forceEmptyQueue = false; + public void execute(SchedulableContext SC) { - List testRuns = [SELECT Id, Parent_Job_Ids__c FROM Test_Run__c WHERE Processed__c = false]; + List testRuns = [ + SELECT Id, Parent_Job_Ids__c, CreatedDate + FROM Test_Run__c + WHERE Processed__c = false + ]; for (Test_Run__c testRun : testRuns) { - if (testRun.Parent_Job_Ids__c != null && allTestsComplete(testRun.Parent_Job_Ids__c)) { - // each item of testResults is the actual result of a single test method - getTestResults(testRun.Parent_Job_Ids__c); - processTestResults(testRun, testResults); - // update the test run - testRun.Processed__c = true; + try { + processTestRun(testRun); + } catch (Exception e) { + testRun.Status__c = 'Failed'; + testRun.Error_Message__c = truncateErrorMessage(e.getMessage()); + testRun.Processed__c = false; update testRun; } } } - private Boolean allTestsComplete(String parentJobIds) { - List classTestStatuses = getClassTestStatuses(parentJobIds); + /** + * Processes a single unprocessed Test_Run__c: abandons vanished queue items, + * waits while tests are still running, or parses completed results. + */ + private void processTestRun(Test_Run__c testRun) { + if (testRun.Parent_Job_Ids__c == null) { + return; + } + + List classTestStatuses = getClassTestStatuses(testRun.Parent_Job_Ids__c); + + if (classTestStatuses == null || classTestStatuses.isEmpty()) { + if (testRun.CreatedDate < Datetime.now().addHours(-VANISHED_QUEUE_TIMEOUT_HOURS)) { + markAbandoned(testRun, ABANDONED_QUEUE_MESSAGE); + } + return; + } - if (classTestStatuses == null || classTestStatuses.size() == 0) { - return false; + if (!isQueueComplete(classTestStatuses)) { + return; } + testRun.Status__c = 'Processing'; + update testRun; + + getTestResults(testRun.Parent_Job_Ids__c); + processTestResults(testRun, testResults); + } + + /** + * Marks a run as abandoned so TestRunProcessor stops retrying it. + */ + private void markAbandoned(Test_Run__c testRun, String errorMessage) { + testRun.Status__c = 'Abandoned'; + testRun.Processed__c = true; + testRun.Error_Message__c = errorMessage; + update testRun; + } + + /** + * Truncates error text to the Error_Message__c field limit. + */ + private static String truncateErrorMessage(String message) { + if (message == null) { + return null; + } + if (message.length() > ERROR_MESSAGE_MAX_LENGTH) { + return message.substring(0, ERROR_MESSAGE_MAX_LENGTH); + } + return message; + } + + private Boolean isQueueComplete(List classTestStatuses) { for (ApexTestQueueItem classTestStatus : classTestStatuses) { if ( classTestStatus.Status != 'Completed' && @@ -53,10 +110,14 @@ public with sharing class TestRunProcessor implements Schedulable { WHERE ParentJobId IN :parentJobIds.split(',') ]; - if (Test.isRunningTest() && !forceIncompleteQueue) { + if (Test.isRunningTest()) { + if (forceEmptyQueue) { + return new List(); + } + String stubStatus = forceIncompleteQueue ? 'Queued' : 'Completed'; queueItems.add( (ApexTestQueueItem) JSON.deserialize( - '{"ApexClass.Name":"TestRunProcessorTest","Status":"Completed"}', + '{"ApexClass.Name":"TestRunProcessorTest","Status":"' + stubStatus + '"}', ApexTestQueueItem.class ) ); @@ -112,6 +173,9 @@ public with sharing class TestRunProcessor implements Schedulable { } processPassFailDates(testRun, failedTests); testRun.Failure_Summary__c = buildFailureSummary(failedTests); + testRun.Processed__c = true; + testRun.Status__c = 'Processed'; + testRun.Error_Message__c = null; update testRun; insert results; } diff --git a/force-app/main/default/classes/TestRunProcessorTest.cls b/force-app/main/default/classes/TestRunProcessorTest.cls index 9c6232a..865ee39 100644 --- a/force-app/main/default/classes/TestRunProcessorTest.cls +++ b/force-app/main/default/classes/TestRunProcessorTest.cls @@ -17,11 +17,12 @@ private class TestRunProcessorTest { Test.stopTest(); testRun = [ - SELECT Processed__c, Failure_Summary__c, New_Failures__c + SELECT Processed__c, Status__c, Failure_Summary__c, New_Failures__c FROM Test_Run__c WHERE Id = :testRun.Id ]; System.assertEquals(true, testRun.Processed__c); + System.assertEquals('Processed', testRun.Status__c); System.assertEquals(1, testRun.New_Failures__c); System.assert(testRun.Failure_Summary__c.contains('FailingClass.failingMethod')); @@ -189,11 +190,12 @@ private class TestRunProcessorTest { Test.stopTest(); testRun = [ - SELECT Processed__c + SELECT Processed__c, Status__c FROM Test_Run__c WHERE Id = :testRun.Id ]; System.assertEquals(false, testRun.Processed__c); + System.assertEquals('Queued', testRun.Status__c); List results = [ SELECT Id @@ -203,6 +205,30 @@ private class TestRunProcessorTest { System.assertEquals(0, results.size()); } + @isTest + static void vanishedQueueTimeoutMarksAbandoned() { + String parentJobId = 'processorVanishedParentId'; + Test_Run__c testRun = new Test_Run__c(Parent_Job_Ids__c = parentJobId, Processed__c = false); + insert testRun; + Test.setCreatedDate(testRun.Id, Datetime.now().addHours(-7)); + + TestRunProcessor processor = new TestRunProcessor(); + processor.forceEmptyQueue = true; + + Test.startTest(); + processor.execute(null); + Test.stopTest(); + + testRun = [ + SELECT Processed__c, Status__c, Error_Message__c + FROM Test_Run__c + WHERE Id = :testRun.Id + ]; + System.assertEquals(true, testRun.Processed__c); + System.assertEquals('Abandoned', testRun.Status__c); + System.assertEquals('No ApexTestQueueItem records found after timeout', testRun.Error_Message__c); + } + @isTest static void historyDoesNotCollideAcrossClasses() { String parentJobId = 'processorHistoryParentId'; diff --git a/force-app/main/default/classes/TestRunScheduler.cls b/force-app/main/default/classes/TestRunScheduler.cls index b2ed915..14bbba1 100644 --- a/force-app/main/default/classes/TestRunScheduler.cls +++ b/force-app/main/default/classes/TestRunScheduler.cls @@ -10,56 +10,90 @@ * TLDR; this class starts the org's unit tests, but does nothing with the results. */ public with sharing class TestRunScheduler implements Schedulable { + private static final Integer ERROR_MESSAGE_MAX_LENGTH = 32768; + public void execute(SchedulableContext SC) { - // get all unit test classes (excluding managed package unit tests) - List testClasses = [FIND '@isTest' IN ALL FIELDS RETURNING ApexClass(Id, Name)][0]; + List parentIds = new List(); + try { + // Discover unit test classes in the org namespace only (managed packages + // excluded via NamespacePrefix = null). SOSL still caps results at 2000 records. + List testClasses = [ + FIND '@isTest' IN ALL FIELDS + RETURNING ApexClass(Id, Name WHERE NamespacePrefix = null) + ][0]; + + if (testClasses.size() > 0) { + List> queueItems = new List>(); + List innerQueueItems = new List(); - if (testClasses.size() > 0) { - List> queueItems = new List>(); - List innerQueueItems = new List(); + for (ApexClass testClass : testClasses) { + innerQueueItems.add(new ApexTestQueueItem(ApexClassId = testClass.Id)); - for (ApexClass testClass : testClasses) { - innerQueueItems.add(new ApexTestQueueItem(ApexClassId = testClass.Id)); + if (innerQueueItems.size() == 200) { + queueItems.add(innerQueueItems); + innerQueueItems = new List(); + } + } - if (innerQueueItems.size() == 200) { + if (innerQueueItems.size() > 0) { queueItems.add(innerQueueItems); - innerQueueItems = new List(); } - } - if (innerQueueItems.size() > 0) { - queueItems.add(innerQueueItems); - } + Set testQueueIds = new Set(); - List parentIds = new List(); - Set testQueueIds = new Set(); + // bulk insert, when bulk inserted they will all contain the same ParentJobId + if (!Test.isRunningTest()) { + // can't queue unit tests while running a test + for (List queueItemsSubset : queueItems) { + insert queueItemsSubset; + testQueueIds.add(queueItemsSubset[0].Id); + } - // bulk insert, when bulk inserted they will all contain the same ParentJobId - if (!Test.isRunningTest()) { - // can't queue unit tests while running a test - for (List queueItemsSubset : queueItems) { - insert queueItemsSubset; - testQueueIds.add(queueItemsSubset[0].Id); + for (ApexTestQueueItem queueItem : [ + SELECT ParentJobId + FROM ApexTestQueueItem + WHERE Id IN :testQueueIds + ]) { + parentIds.add(queueItem.ParentJobId); + } } - for (ApexTestQueueItem queueItem : [ - SELECT ParentJobId - FROM ApexTestQueueItem - WHERE Id IN :testQueueIds - ]) { - parentIds.add(queueItem.ParentJobId); - } + Test_Run__c newRun = new Test_Run__c( + Name = 'Test Run: ' + String.valueOf(DateTime.now()), + Parent_Job_Ids__c = Test.isRunningTest() ? 'testingParentJobId' : String.join(parentIds, ','), + Processed__c = false, + Status__c = 'Queued' + ); + + insert newRun; } + } catch (Exception e) { + persistSchedulerFailure(parentIds, e); + } finally { + System.enqueueJob(new TestRunCleanup()); + } + } - Test_Run__c newRun = new Test_Run__c( - Name = 'Test Run: ' + String.valueOf(DateTime.now()), - Parent_Job_Ids__c = Test.isRunningTest() ? 'testingParentJobId' : String.join(parentIds, ','), - Processed__c = false + /** + * Best-effort Test_Run__c row so enqueue/insert failures are visible in the UI. + * If parent job IDs were captured, leave the run unprocessed so the processor can retry. + */ + private static void persistSchedulerFailure(List parentIds, Exception e) { + try { + Boolean hasJobs = parentIds != null && !parentIds.isEmpty(); + String errorMessage = e == null ? 'Unknown scheduler error' : e.getMessage(); + if (errorMessage != null && errorMessage.length() > ERROR_MESSAGE_MAX_LENGTH) { + errorMessage = errorMessage.substring(0, ERROR_MESSAGE_MAX_LENGTH); + } + insert new Test_Run__c( + Name = 'Test Run Failed: ' + String.valueOf(Datetime.now()), + Parent_Job_Ids__c = hasJobs ? String.join(parentIds, ',') : 'unavailable', + Processed__c = !hasJobs, + Status__c = 'Failed', + Error_Message__c = errorMessage ); - - insert newRun; + } catch (Exception ignore) { + // Original failure is already handled; cleanup still runs in finally. } - - System.enqueueJob(new TestRunCleanup()); } } diff --git a/force-app/main/default/classes/TestRunSchedulerTest.cls b/force-app/main/default/classes/TestRunSchedulerTest.cls index 1591406..cca39d5 100644 --- a/force-app/main/default/classes/TestRunSchedulerTest.cls +++ b/force-app/main/default/classes/TestRunSchedulerTest.cls @@ -13,14 +13,11 @@ private class TestRunSchedulerTest { new TestRunScheduler().execute(null); Test.stopTest(); - List runs = [ - SELECT Parent_Job_Ids__c, Processed__c - FROM Test_Run__c - WHERE Parent_Job_Ids__c = 'testingParentJobId' - ]; + List runs = runsWithParentJobId('testingParentJobId'); System.assertEquals(1, runs.size()); System.assertEquals('testingParentJobId', runs[0].Parent_Job_Ids__c); System.assertEquals(false, runs[0].Processed__c); + System.assertEquals('Queued', runs[0].Status__c); } @isTest @@ -31,11 +28,22 @@ private class TestRunSchedulerTest { new TestRunScheduler().execute(null); Test.stopTest(); - List runs = [ - SELECT Id + System.assertEquals(0, runsWithParentJobId('testingParentJobId').size()); + } + + /** + * Long text Parent_Job_Ids__c cannot be filtered in SOQL; match in Apex. + */ + private static List runsWithParentJobId(String parentJobId) { + List matches = new List(); + for (Test_Run__c candidate : [ + SELECT Parent_Job_Ids__c, Processed__c, Status__c FROM Test_Run__c - WHERE Parent_Job_Ids__c = 'testingParentJobId' - ]; - System.assertEquals(0, runs.size()); + ]) { + if (candidate.Parent_Job_Ids__c == parentJobId) { + matches.add(candidate); + } + } + return matches; } } diff --git a/force-app/main/default/layouts/Test_Run_Method_Result__c-Automated Test Job Result Layout.layout-meta.xml b/force-app/main/default/layouts/Test_Run_Method_Result__c-Automated Test Job Result Layout.layout-meta.xml index 8569792..d7e008a 100644 --- a/force-app/main/default/layouts/Test_Run_Method_Result__c-Automated Test Job Result Layout.layout-meta.xml +++ b/force-app/main/default/layouts/Test_Run_Method_Result__c-Automated Test Job Result Layout.layout-meta.xml @@ -23,6 +23,22 @@ Edit Method_Pass__c + + Edit + Is_Failure__c + + + Edit + Outcome__c + + + Edit + New_Failure__c + + + Edit + Run_Time__c + Edit First_Failure__c diff --git a/force-app/main/default/layouts/Test_Run__c-Test Run Layout.layout-meta.xml b/force-app/main/default/layouts/Test_Run__c-Test Run Layout.layout-meta.xml index 6ad2012..959514a 100644 --- a/force-app/main/default/layouts/Test_Run__c-Test Run Layout.layout-meta.xml +++ b/force-app/main/default/layouts/Test_Run__c-Test Run Layout.layout-meta.xml @@ -15,12 +15,32 @@ Readonly Test_Failures__c + + Readonly + New_Failures__c + + + Readonly + Total_Run_Time__c + Edit Processed__c + + Edit + Status__c + + + Edit + Failure_Summary__c + + + Edit + Error_Message__c + @@ -31,7 +51,7 @@ - Required + Edit Parent_Job_Ids__c @@ -65,6 +85,9 @@ NAME Method_Name__c Method_Pass__c + New_Failure__c + Run_Time__c + First_Failure__c Message__c Test_Run_Method_Result__c.Test_Run__c Method_Pass__c @@ -82,4 +105,3 @@ Default - diff --git a/force-app/main/default/objects/Test_Run_Method_Result__c/fields/New_Failure__c.field-meta.xml b/force-app/main/default/objects/Test_Run_Method_Result__c/fields/New_Failure__c.field-meta.xml index 4001e3d..7bf8cca 100644 --- a/force-app/main/default/objects/Test_Run_Method_Result__c/fields/New_Failure__c.field-meta.xml +++ b/force-app/main/default/objects/Test_Run_Method_Result__c/fields/New_Failure__c.field-meta.xml @@ -3,7 +3,7 @@ New_Failure__c false false - + false Checkbox diff --git a/force-app/main/default/objects/Test_Run_Method_Result__c/listViews/Failed_Methods.listView-meta.xml b/force-app/main/default/objects/Test_Run_Method_Result__c/listViews/Failed_Methods.listView-meta.xml new file mode 100644 index 0000000..53dcba3 --- /dev/null +++ b/force-app/main/default/objects/Test_Run_Method_Result__c/listViews/Failed_Methods.listView-meta.xml @@ -0,0 +1,15 @@ + + + Failed_Methods + NAME + Method_Name__c + Test_Run__c + New_Failure__c + Everything + + Is_Failure__c + equals + 1 + + + diff --git a/force-app/main/default/objects/Test_Run__c/fields/Error_Message__c.field-meta.xml b/force-app/main/default/objects/Test_Run__c/fields/Error_Message__c.field-meta.xml new file mode 100644 index 0000000..68b287e --- /dev/null +++ b/force-app/main/default/objects/Test_Run__c/fields/Error_Message__c.field-meta.xml @@ -0,0 +1,11 @@ + + + Error_Message__c + Last processing error for this test run + false + + 32768 + false + LongTextArea + 3 + diff --git a/force-app/main/default/objects/Test_Run__c/fields/Parent_Job_Ids__c.field-meta.xml b/force-app/main/default/objects/Test_Run__c/fields/Parent_Job_Ids__c.field-meta.xml index 1c01884..e731e5c 100644 --- a/force-app/main/default/objects/Test_Run__c/fields/Parent_Job_Ids__c.field-meta.xml +++ b/force-app/main/default/objects/Test_Run__c/fields/Parent_Job_Ids__c.field-meta.xml @@ -1,13 +1,9 @@ Parent_Job_Ids__c - false - true - 255 - true + 32768 false - Text - true + LongTextArea + 3 - diff --git a/force-app/main/default/objects/Test_Run__c/fields/Status__c.field-meta.xml b/force-app/main/default/objects/Test_Run__c/fields/Status__c.field-meta.xml new file mode 100644 index 0000000..1cf9939 --- /dev/null +++ b/force-app/main/default/objects/Test_Run__c/fields/Status__c.field-meta.xml @@ -0,0 +1,41 @@ + + + Status__c + Lifecycle status of the scheduled test run + false + + false + false + Picklist + + true + + false + + Queued + true + + + + Processing + false + + + + Processed + false + + + + Abandoned + false + + + + Failed + false + + + + + diff --git a/force-app/main/default/objects/Test_Run__c/listViews/Unprocessed_Runs.listView-meta.xml b/force-app/main/default/objects/Test_Run__c/listViews/Unprocessed_Runs.listView-meta.xml new file mode 100644 index 0000000..4c5eafd --- /dev/null +++ b/force-app/main/default/objects/Test_Run__c/listViews/Unprocessed_Runs.listView-meta.xml @@ -0,0 +1,14 @@ + + + Unprocessed_Runs + NAME + Status__c + Processed__c + Everything + + Processed__c + equals + 0 + + + diff --git a/force-app/main/default/permissionsets/Test_Runner.permissionset-meta.xml b/force-app/main/default/permissionsets/Test_Runner.permissionset-meta.xml index 77a4919..e5c54dd 100644 --- a/force-app/main/default/permissionsets/Test_Runner.permissionset-meta.xml +++ b/force-app/main/default/permissionsets/Test_Runner.permissionset-meta.xml @@ -1,5 +1,25 @@ + + true + Test_Run_Method_Result__c.First_Failure__c + true + + + false + Test_Run_Method_Result__c.Failure_Audit__c + true + + + true + Test_Run_Method_Result__c.Is_Failure__c + true + + + true + Test_Run_Method_Result__c.Last_Success__c + true + true Test_Run_Method_Result__c.Message__c @@ -15,21 +35,61 @@ Test_Run_Method_Result__c.Method_Pass__c true + + true + Test_Run_Method_Result__c.New_Failure__c + true + + + true + Test_Run_Method_Result__c.Outcome__c + true + + + true + Test_Run_Method_Result__c.Run_Time__c + true + true Test_Run_Method_Result__c.Stack_Trace__c true + + true + Test_Run__c.Error_Message__c + true + + + true + Test_Run__c.Failure_Summary__c + true + + + true + Test_Run__c.New_Failures__c + true + true Test_Run__c.Processed__c true + + true + Test_Run__c.Status__c + true + false Test_Run__c.Test_Failures__c true + + false + Test_Run__c.Total_Run_Time__c + true + false Salesforce @@ -51,4 +111,12 @@ Test_Run__c true + + Test_Run_Method_Result__c + Visible + + + Test_Run__c + Visible + diff --git a/force-app/main/default/tabs/Test_Run_Method_Result__c.tab-meta.xml b/force-app/main/default/tabs/Test_Run_Method_Result__c.tab-meta.xml new file mode 100644 index 0000000..15d695a --- /dev/null +++ b/force-app/main/default/tabs/Test_Run_Method_Result__c.tab-meta.xml @@ -0,0 +1,5 @@ + + + true + Custom53: Hands + diff --git a/force-app/main/default/tabs/Test_Run__c.tab-meta.xml b/force-app/main/default/tabs/Test_Run__c.tab-meta.xml new file mode 100644 index 0000000..15d695a --- /dev/null +++ b/force-app/main/default/tabs/Test_Run__c.tab-meta.xml @@ -0,0 +1,5 @@ + + + true + Custom53: Hands + From 7f56ea00178a7d0021a8ffc6452f379b532fd86a Mon Sep 17 00:00:00 2001 From: FishyGeek Date: Thu, 13 Aug 2026 12:40:39 -0600 Subject: [PATCH 3/4] feat: replace Workflow Rule with inactive failure Flow (#20) Ship a Draft record-triggered Flow with a PRIORVALUE guard and an owner-based email alert. The VF template is UTF-8, caps rows at 200, and uses org-domain URLs. Deploy with sf project deploy start. Co-authored-by: Cursor --- CHANGELOG.md | 30 +++++++ README.md | 20 +++-- .../classes/TestRunEmailController.cls | 49 +++++++++-- .../TestRunEmailController.cls-meta.xml | 2 +- .../classes/TestRunEmailControllerTest.cls | 72 +++++++++++++++- .../components/FailureTable.component | 12 ++- .../Test_Run_VF_Notification.email-meta.xml | 4 +- ...est_Run_Failure_Notification.flow-meta.xml | 86 +++++++++++++++++++ .../workflows/Test_Run__c.workflow-meta.xml | 23 +---- 9 files changed, 257 insertions(+), 41 deletions(-) create mode 100644 CHANGELOG.md create mode 100644 notifications/workflow-email/flows/Test_Run_Failure_Notification.flow-meta.xml diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..6bf5f8b --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,30 @@ +# Changelog + +All notable changes to Scheduled Test Runner will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +### Added +- Test run lifecycle status (`Status__c`) and error message (`Error_Message__c`) fields ([#18](https://github.com/callawaycloud/ScheduledTestRunner/issues/18)) +- Custom tabs and list views (Unprocessed Runs, Failed Methods) ([#18](https://github.com/callawaycloud/ScheduledTestRunner/issues/18)) +- Record-triggered Flow (`Test Run Failure Notification`) for failure emails; ships inactive (`Draft`) under `notifications/workflow-email` ([#20](https://github.com/callawaycloud/ScheduledTestRunner/issues/20)) + +### Changed +- Retention prune uses a Datetime bind and runs in a Queueable; stuck unprocessed runs are abandoned ([#17](https://github.com/callawaycloud/ScheduledTestRunner/issues/17)) +- First-failure tracking keys on class + method; Skip is not counted as a failure ([#17](https://github.com/callawaycloud/ScheduledTestRunner/issues/17)) +- Unit test discovery excludes managed package classes via SOSL `NamespacePrefix = null` ([#18](https://github.com/callawaycloud/ScheduledTestRunner/issues/18)) +- `Parent_Job_Ids__c` is a long text area (up to 32,768 characters) instead of a unique external id ([#18](https://github.com/callawaycloud/ScheduledTestRunner/issues/18)) +- Permission set FLS, layouts, and the `New Failure` field label match the packaged fields ([#18](https://github.com/callawaycloud/ScheduledTestRunner/issues/18)) +- Workflow Rule replaced by a Flow-driven email alert; placeholder `ccEmails` removed ([#20](https://github.com/callawaycloud/ScheduledTestRunner/issues/20)) +- Failure email is UTF-8, uses `(NEW)` instead of an emoji, org-domain URLs, and caps displayed rows at 200 ([#20](https://github.com/callawaycloud/ScheduledTestRunner/issues/20)) + +## [1.0.0] - 2020-09-10 + +### Added +- Initial project scaffolding + +[Unreleased]: https://github.com/callawaycloud/ScheduledTestRunner/compare/v1.0.0...HEAD +[1.0.0]: https://github.com/callawaycloud/ScheduledTestRunner/releases/tag/v1.0.0 diff --git a/README.md b/README.md index 42cf884..5c5b8f1 100644 --- a/README.md +++ b/README.md @@ -49,17 +49,23 @@ To keep things as flexible & upgradable possible, we've decided not to package a Since the results are stored in an object, you can you use tool of choice to receive notifications about failures. -However, we have included a simple workflow to send an email when a test fails. +We include optional notification metadata (email template, Visualforce component, email alert, and a record-triggered Flow) under `notifications/workflow-email`. The Flow ships **inactive** (`Draft`) so you can configure recipients before it sends mail. -**To install workflow email alert:** +**To deploy notification metadata:** -- run `sfdx force:source:convert -r notifications/workflow-email -d ./dist/workflow-email` to create a metadata package -- run `sfdx force:mdapi:deploy -d ./dist/workflow-email/ -w -1` to deploy -- Update Workflow Action "Test Run Failure Notification Alert", to include your email addresses +```bash +sf project deploy start --source-dir notifications/workflow-email --target-org +``` -We may add more prebuilt notification methods in the future. +**After deploy:** + +1. Open **Setup β†’ Email Alerts** and edit **Test Run Failure Notification Alert** to add recipient email addresses (the packaged alert has no default recipients). +2. Open **Setup β†’ Flows**, open **Test Run Failure Notification**, review entry criteria (`Processed__c = true` and `Test_Failures__c > 0`, with a PRIORVALUE guard on `Processed__c`), then **Activate** the Flow when ready. +3. Prefer an **org-wide email address** as the alert sender (Setup β†’ Organization-Wide Addresses). The alert metadata uses `CurrentUser` because org-wide address IDs are org-specific; switch the sender in the alert before activating if your org has a suitable address. -**TIP: If you only want to be notified of new failures, update the workflow to send if `Test_Run__r.New_Failures__c > 0`** +**TIP: If you only want to be notified of new failures, update the Flow entry criteria to use `Test_Run__c.New_Failures__c > 0` (field on `Test_Run__c`, not `Test_Run__r`).** + +We may add more prebuilt notification methods in the future. ## Development diff --git a/notifications/workflow-email/classes/TestRunEmailController.cls b/notifications/workflow-email/classes/TestRunEmailController.cls index d6776c6..c2ce604 100644 --- a/notifications/workflow-email/classes/TestRunEmailController.cls +++ b/notifications/workflow-email/classes/TestRunEmailController.cls @@ -1,11 +1,35 @@ -public class TestRunEmailController { - List queryResults; +/** + * Visualforce controller for the optional Test Run failure email table. + * Caps displayed failures at 200 and links remaining rows to the Test Run record. + */ +public with sharing class TestRunEmailController { + private static final Integer MAX_FAILURE_ROWS = 200; + + private List queryResults; + private Boolean hasMoreFailures; + public Id testRunId { get; set; } + + /** + * Org-domain base URL for record links in the email (not the My Domain-unaware instance URL). + */ public String getbaseURL() { - String baseURL = URL.getSalesforceBaseUrl().toExternalForm(); - return baseURL; + return URL.getOrgDomainUrl().toExternalForm(); } + /** + * True when more than MAX_FAILURE_ROWS failures exist for the Test Run. + */ + public Boolean getHasMoreFailures() { + if (queryResults == null) { + queryTestRun(); + } + return hasMoreFailures == true; + } + + /** + * Failed methods grouped by Apex class name. Skip/Pass rows are excluded. + */ public Map> getFailureMap() { if (queryResults == null) { queryTestRun(); @@ -21,11 +45,22 @@ public class TestRunEmailController { return failureMap; } + /** + * Loads Fail/CompileFail children only, newest-failure first, plus one extra row to detect overflow. + */ private void queryTestRun() { - queryResults = [ + Integer queryLimit = MAX_FAILURE_ROWS + 1; + List results = [ SELECT Id, Name, Method_Name__c, Message__c, First_Failure__c, New_Failure__c FROM Test_Run_Method_Result__c - WHERE Test_Run__c = :testRunId AND Method_Pass__c = false + WHERE Test_Run__c = :testRunId AND Is_Failure__c = true + ORDER BY New_Failure__c DESC, Name, Method_Name__c + LIMIT :queryLimit ]; + hasMoreFailures = results.size() > MAX_FAILURE_ROWS; + if (hasMoreFailures) { + results.remove(results.size() - 1); + } + queryResults = results; } -} \ No newline at end of file +} diff --git a/notifications/workflow-email/classes/TestRunEmailController.cls-meta.xml b/notifications/workflow-email/classes/TestRunEmailController.cls-meta.xml index db9bf8c..8e4d11f 100644 --- a/notifications/workflow-email/classes/TestRunEmailController.cls-meta.xml +++ b/notifications/workflow-email/classes/TestRunEmailController.cls-meta.xml @@ -1,5 +1,5 @@ - 48.0 + 49.0 Active diff --git a/notifications/workflow-email/classes/TestRunEmailControllerTest.cls b/notifications/workflow-email/classes/TestRunEmailControllerTest.cls index 0667384..9256434 100644 --- a/notifications/workflow-email/classes/TestRunEmailControllerTest.cls +++ b/notifications/workflow-email/classes/TestRunEmailControllerTest.cls @@ -15,7 +15,10 @@ private class TestRunEmailControllerTest { Method_Name__c = 'Test Method', Message__c = 'Testing email controller', Method_Pass__c = false, + Is_Failure__c = true, + Outcome__c = 'Fail', First_Failure__c = Datetime.now(), + New_Failure__c = true, Test_Run__c = testRun.Id ), new Test_Run_Method_Result__c( @@ -23,7 +26,28 @@ private class TestRunEmailControllerTest { Method_Name__c = 'Test Method2', Message__c = 'Testing email controller', Method_Pass__c = false, + Is_Failure__c = true, + Outcome__c = 'Fail', First_Failure__c = Datetime.now().addDays(-1), + New_Failure__c = false, + Test_Run__c = testRun.Id + ), + new Test_Run_Method_Result__c( + Name = 'Passing Class', + Method_Name__c = 'Passing Method', + Message__c = 'Should be excluded', + Method_Pass__c = true, + Is_Failure__c = false, + Outcome__c = 'Pass', + Test_Run__c = testRun.Id + ), + new Test_Run_Method_Result__c( + Name = 'Skip Class', + Method_Name__c = 'Skipped Method', + Message__c = 'Skip should be excluded', + Method_Pass__c = false, + Is_Failure__c = false, + Outcome__c = 'Skip', Test_Run__c = testRun.Id ) }; @@ -31,8 +55,54 @@ private class TestRunEmailControllerTest { TestRunEmailController cont = new TestRunEmailController(); cont.testRunId = testRun.Id; + + System.assert(String.isNotBlank(cont.getbaseURL()), 'Base URL should be populated'); + Map> failMap = cont.getFailureMap(); System.assert(failMap.keySet().contains(methodResults[0].Name)); System.assert(failMap.keySet().contains(methodResults[1].Name)); + System.assert(!failMap.keySet().contains(methodResults[2].Name), 'Passing rows should be excluded'); + System.assert(!failMap.keySet().contains(methodResults[3].Name), 'Skip rows should be excluded'); + System.assertEquals(1, failMap.get(methodResults[0].Name).size()); + System.assertEquals(methodResults[0].Id, failMap.get(methodResults[0].Name)[0].Id); + System.assertEquals(false, cont.getHasMoreFailures()); + } + + @isTest + public static void controllerTest_hasMoreFailures() { + Test_Run__c testRun = new Test_Run__c( + Name = 'Overflow Test Run', + Parent_Job_Ids__c = '7073t00005OHeNPCA1', + Processed__c = false + ); + insert testRun; + + List methodResults = new List(); + for (Integer i = 0; i < 201; i++) { + methodResults.add( + new Test_Run_Method_Result__c( + Name = 'Overflow Class ' + i, + Method_Name__c = 'Method ' + i, + Message__c = 'Overflow row', + Method_Pass__c = false, + Is_Failure__c = true, + Outcome__c = 'Fail', + Test_Run__c = testRun.Id + ) + ); + } + insert methodResults; + + TestRunEmailController cont = new TestRunEmailController(); + cont.testRunId = testRun.Id; + + Map> failMap = cont.getFailureMap(); + Integer rowCount = 0; + for (List rows : failMap.values()) { + rowCount += rows.size(); + } + + System.assertEquals(200, rowCount, 'Failure table should cap at 200 rows'); + System.assertEquals(true, cont.getHasMoreFailures()); } -} \ No newline at end of file +} diff --git a/notifications/workflow-email/components/FailureTable.component b/notifications/workflow-email/components/FailureTable.component index c498d1e..ccff6dd 100644 --- a/notifications/workflow-email/components/FailureTable.component +++ b/notifications/workflow-email/components/FailureTable.component @@ -18,15 +18,21 @@ - {!IF(result.New_Failure__c, 'πŸ†•', '')} - {!result.Method_Name__c} + {!IF(result.New_Failure__c, '(NEW) ', '')} + {!result.Method_Name__c} {!result.Message__c} - + + + + Additional failures omitted; open the Test Run record. + + + \ No newline at end of file diff --git a/notifications/workflow-email/email/Test_Runner_Email_Templates/Test_Run_VF_Notification.email-meta.xml b/notifications/workflow-email/email/Test_Runner_Email_Templates/Test_Run_VF_Notification.email-meta.xml index 8d5aa95..7da6ca2 100644 --- a/notifications/workflow-email/email/Test_Runner_Email_Templates/Test_Run_VF_Notification.email-meta.xml +++ b/notifications/workflow-email/email/Test_Runner_Email_Templates/Test_Run_VF_Notification.email-meta.xml @@ -3,10 +3,10 @@ 49.0 true Visual Force Email Template for Test Runner - ISO-8859-1 + UTF-8 Test Run VF Notification - Test Failures + Test Run Failure Notification visualforce Aloha diff --git a/notifications/workflow-email/flows/Test_Run_Failure_Notification.flow-meta.xml b/notifications/workflow-email/flows/Test_Run_Failure_Notification.flow-meta.xml new file mode 100644 index 0000000..bdc4bf4 --- /dev/null +++ b/notifications/workflow-email/flows/Test_Run_Failure_Notification.flow-meta.xml @@ -0,0 +1,86 @@ + + + 59.0 + Sends the Test Run failure email when Processed becomes true and failures exist. Inactive by default β€” activate after configuring Email Alert recipients and (preferably) an org-wide from address. Uses doesRequireRecordChangedToMeetCriteria plus a PRIORVALUE guard on Processed__c so it does not fire on later edits of an already-processed run. + Test Run Failure Notification {!$Flow.CurrentDateTime} + + + BuilderType + + LightningFlowBuilder + + + AutoLaunchedFlow + Draft + + Send_Test_Run_Failure_Alert + + 176 + 335 + Test_Run__c.Test_Run_Failure_Notification_Alert + emailAlert + CurrentTransaction + + SObjectRowId + + $Record.Id + + + Test_Run__c.Test_Run_Failure_Notification_Alert + + + Processed_Just_Became_True + + 176 + 158 + Skip Notification + + Prior_Processed_Was_False_Or_Create + or + + $Record__Prior.Processed__c + EqualTo + + false + + + + $Record__Prior.Processed__c + IsNull + + true + + + + Send_Test_Run_Failure_Alert + + + + + + 50 + 0 + + Processed_Just_Became_True + + true + and + + Processed__c + EqualTo + + true + + + + Test_Failures__c + GreaterThan + + 0.0 + + + Test_Run__c + CreateAndUpdate + RecordAfterSave + + diff --git a/notifications/workflow-email/workflows/Test_Run__c.workflow-meta.xml b/notifications/workflow-email/workflows/Test_Run__c.workflow-meta.xml index fd6709d..ac0eb82 100644 --- a/notifications/workflow-email/workflows/Test_Run__c.workflow-meta.xml +++ b/notifications/workflow-email/workflows/Test_Run__c.workflow-meta.xml @@ -2,29 +2,12 @@ Test_Run_Failure_Notification_Alert - replace@me.com Test Run Failure Notification Alert false + + owner + CurrentUser - - Send Test Run Failure Notification - - Test_Run_Failure_Notification_Alert - Alert - - true - - Test_Run__c.Test_Failures__c - greaterThan - 0 - - - Test_Run__c.Processed__c - equals - True - - onCreateOrTriggeringUpdate - From 2df342e757790234dff7ac9e79f1e76fb688ee86 Mon Sep 17 00:00:00 2001 From: FishyGeek Date: Thu, 13 Aug 2026 12:58:37 -0600 Subject: [PATCH 4/4] chore: modernize DX, docs, and CI (#19) Align metadata to API 59, document sf CLI and perm-set setup, ignore .sf/, turn off deploy-on-save, and add a scratch-org GitHub Action. Co-authored-by: Cursor --- .github/workflows/ci.yml | 47 +++++++++++ .gitignore | 4 + .prettierrc | 1 + .vscode/settings.json | 8 +- CHANGELOG.md | 6 ++ README.md | 80 +++++++++++++------ config/project-scratch-def.json | 8 +- .../classes/TestRunCleanup.cls-meta.xml | 2 +- .../classes/TestRunCleanupTest.cls-meta.xml | 2 +- .../classes/TestRunProcessor.cls-meta.xml | 2 +- .../classes/TestRunProcessorTest.cls-meta.xml | 2 +- .../classes/TestRunScheduler.cls-meta.xml | 2 +- .../classes/TestRunSchedulerTest.cls-meta.xml | 2 +- .../TestRunEmailController.cls-meta.xml | 2 +- .../TestRunEmailControllerTest.cls-meta.xml | 2 +- .../FailureTable.component-meta.xml | 2 +- .../Test_Run_VF_Notification.email-meta.xml | 2 +- package.json | 4 +- sfdx-project.json | 4 +- 19 files changed, 137 insertions(+), 45 deletions(-) create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..bfcc2c8 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,47 @@ +name: CI + +on: + pull_request: + push: + branches: + - master + +jobs: + apex-tests: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: "20" + + - name: Install Salesforce CLI + run: npm install -g @salesforce/cli + + - name: Authenticate Dev Hub + env: + DEVHUB_AUTH_URL: ${{ secrets.DEVHUB_AUTH_URL }} + run: | + if [ -z "$DEVHUB_AUTH_URL" ]; then + echo "DEVHUB_AUTH_URL secret is not set" + exit 1 + fi + printenv DEVHUB_AUTH_URL > devhub-auth-url.txt + sf org login sfdx-url --sfdx-url-file devhub-auth-url.txt --alias devhub --set-default-dev-hub + rm -f devhub-auth-url.txt + + - name: Create scratch org + run: sf org create scratch --definition-file config/project-scratch-def.json --alias ci --duration-days 1 --wait 10 --target-dev-hub devhub + + - name: Deploy force-app + run: sf project deploy start --source-dir force-app --target-org ci --wait 10 + + - name: Run Apex tests + run: sf apex run test --target-org ci --wait 10 --code-coverage --result-format human + + - name: Delete scratch org + if: always() + run: sf org delete scratch --target-org ci --no-prompt diff --git a/.gitignore b/.gitignore index a9a0305..4d52200 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,10 @@ # Salesforce cache .sfdx/ +.sf/ + +# Cursor +.cursor/ # Logs logs diff --git a/.prettierrc b/.prettierrc index ab9e495..a6b6296 100644 --- a/.prettierrc +++ b/.prettierrc @@ -1,4 +1,5 @@ { + "plugins": ["prettier-plugin-apex"], "trailingComma": "none", "overrides": [ { diff --git a/.vscode/settings.json b/.vscode/settings.json index d3b3fa5..ad7cd00 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,12 +1,14 @@ { - "salesforcedx-vscode-core.push-or-deploy-on-save.enabled": true, + "salesforcedx-vscode-core.push-or-deploy-on-save.enabled": false, "search.exclude": { "**/node_modules": true, "**/bower_components": true, - "**/.sfdx": true + "**/.sfdx": true, + "**/.sf": true }, "editor.tabSize": 4, "editor.formatOnSave": true, "editor.formatOnSaveTimeout": 10000, - "eslint.enable": false + "eslint.enable": false, + "xml.preferences.showSchemaDocumentationType": "none" } diff --git a/CHANGELOG.md b/CHANGELOG.md index 6bf5f8b..a4053ee 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,11 +8,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Added +- GitHub Actions CI workflow runs Apex tests in a scratch org on pull requests and pushes to `master` ([#19](https://github.com/callawaycloud/ScheduledTestRunner/issues/19)) - Test run lifecycle status (`Status__c`) and error message (`Error_Message__c`) fields ([#18](https://github.com/callawaycloud/ScheduledTestRunner/issues/18)) - Custom tabs and list views (Unprocessed Runs, Failed Methods) ([#18](https://github.com/callawaycloud/ScheduledTestRunner/issues/18)) - Record-triggered Flow (`Test Run Failure Notification`) for failure emails; ships inactive (`Draft`) under `notifications/workflow-email` ([#20](https://github.com/callawaycloud/ScheduledTestRunner/issues/20)) ### Changed +- README documents permission set assignment, automatic cleanup, scratch-org development, `sf` CLI package commands, and the `DEVHUB_AUTH_URL` CI secret ([#19](https://github.com/callawaycloud/ScheduledTestRunner/issues/19)) +- Apex, email, and Visualforce metadata API versions aligned to 59.0; `versionName` matches `0.1.7` ([#19](https://github.com/callawaycloud/ScheduledTestRunner/issues/19)) +- `package.json` license is MIT; `npm test` runs Apex tests via Salesforce CLI ([#19](https://github.com/callawaycloud/ScheduledTestRunner/issues/19)) +- Prettier Apex plugin wired in `.prettierrc`; deploy-on-save disabled in VS Code settings ([#19](https://github.com/callawaycloud/ScheduledTestRunner/issues/19)) +- Scratch org definition uses a generic org name and current settings shape; `.sf/` is gitignored ([#19](https://github.com/callawaycloud/ScheduledTestRunner/issues/19)) - Retention prune uses a Datetime bind and runs in a Queueable; stuck unprocessed runs are abandoned ([#17](https://github.com/callawaycloud/ScheduledTestRunner/issues/17)) - First-failure tracking keys on class + method; Skip is not counted as a failure ([#17](https://github.com/callawaycloud/ScheduledTestRunner/issues/17)) - Unit test discovery excludes managed package classes via SOSL `NamespacePrefix = null` ([#18](https://github.com/callawaycloud/ScheduledTestRunner/issues/18)) diff --git a/README.md b/README.md index 5c5b8f1..220fb0e 100644 --- a/README.md +++ b/README.md @@ -1,17 +1,16 @@ # Scheduled Test Runner -A Salesforce package to help monitor organization's unit tests health. +A Salesforce package to help monitor your organization's unit test health. Test_Run__2020-05-05_03_00_07___Salesforce -- Setup to run via scheduled process -- Test results are store in objects - - `Test_Run__c`: A test Job instance - - `Test_Run_Method_Result__c`: An individual test method result - - Results are pruned after 30 days -- Tracks "First Failure" to help with debugging / noise reduction -- Automaticly builds Audit Log query to help troubleshoot what changes might have caused a test to fail -- Captures Run Time, which can help identify performance issues or slow tests +- Setup to run via scheduled process +- Test results are stored in objects + - `Test_Run__c`: A test job instance + - `Test_Run_Method_Result__c`: An individual test method result +- Tracks "First Failure" to help with debugging / noise reduction +- Automatically builds Audit Log query to help troubleshoot what changes might have caused a test to fail +- Captures Run Time, which can help identify performance issues or slow tests ## Install @@ -19,8 +18,14 @@ A Salesforce package to help monitor organization's unit tests health. ## Setup +### Permission set + +Assign the **Test Runner** permission set (`Test_Runner`) to users who need the UI (tabs, list views, field-level security). Missing FLS does not break scheduled Apexβ€”the schedulers run as the user who scheduled them. + ### Scheduling +Schedule **TestRunScheduler** and **TestRunProcessor** as a user who can query Apex tests (typically someone with **Author Apex** or **System Administrator**). + 1. Schedule the unit test run frequency (example below runs daily at 3am): ```java @@ -39,15 +44,16 @@ System.Schedule('Test Processor', sch, testProcessor); **NOTES:** -- If you find your test runs are failing inconsistently, you may need to [disabling "parallel" test runs](https://developer.salesforce.com/docs/atlas.en-us.apexcode.meta/apexcode/apex_testing_best_practices.htm) +- If you find your test runs are failing inconsistently, you may need to [disable "parallel" test runs](https://developer.salesforce.com/docs/atlas.en-us.apexcode.meta/apexcode/apex_testing_best_practices.htm). +- Cleanup is automatic: **TestRunScheduler** enqueues **TestRunCleanup**, which prunes processed runs after 30 days and abandons unprocessed runs after 2 days. ### Sending Notifications ![Screen Shot 2020-07-31 at 1 41 14 PM](https://user-images.githubusercontent.com/5217568/89072011-08a54e00-d335-11ea-9ba9-10c5a03cb8ee.png) -To keep things as flexible & upgradable possible, we've decided not to package any notification logic with the "unlocked" package. +To keep things as flexible and upgradable as possible, we've decided not to package any notification logic with the unlocked package. -Since the results are stored in an object, you can you use tool of choice to receive notifications about failures. +Since the results are stored in an object, you can use a tool of choice to receive notifications about failures. We include optional notification metadata (email template, Visualforce component, email alert, and a record-triggered Flow) under `notifications/workflow-email`. The Flow ships **inactive** (`Draft`) so you can configure recipients before it sends mail. @@ -59,9 +65,9 @@ sf project deploy start --source-dir notifications/workflow-email --target-org < **After deploy:** -1. Open **Setup β†’ Email Alerts** and edit **Test Run Failure Notification Alert** to add recipient email addresses (the packaged alert has no default recipients). -2. Open **Setup β†’ Flows**, open **Test Run Failure Notification**, review entry criteria (`Processed__c = true` and `Test_Failures__c > 0`, with a PRIORVALUE guard on `Processed__c`), then **Activate** the Flow when ready. -3. Prefer an **org-wide email address** as the alert sender (Setup β†’ Organization-Wide Addresses). The alert metadata uses `CurrentUser` because org-wide address IDs are org-specific; switch the sender in the alert before activating if your org has a suitable address. +1. Open **Setup β†’ Email Alerts** and review **Test Run Failure Notification Alert**. The alert defaults to the Test Run **owner**; admins can add more recipients. +2. Prefer an **org-wide email address** as the alert sender (Setup β†’ Organization-Wide Addresses). Metadata cannot ship an org-specific org-wide address Id, so `senderType` stays `CurrentUser` until you switch it in Setup. +3. Open **Setup β†’ Flows**, open **Test Run Failure Notification**, review entry criteria (`Processed__c = true` and `Test_Failures__c > 0`, with a PRIORVALUE guard on `Processed__c`), then **Activate** the Flow when the alert is configured. **TIP: If you only want to be notified of new failures, update the Flow entry criteria to use `Test_Run__c.New_Failures__c > 0` (field on `Test_Run__c`, not `Test_Run__r`).** @@ -69,18 +75,44 @@ We may add more prebuilt notification methods in the future. ## Development +### Scratch org + +```bash +sf org create scratch --definition-file config/project-scratch-def.json --alias str-scratch --duration-days 7 --wait 10 +sf project deploy start --source-dir force-app --target-org str-scratch --wait 10 +sf org assign permset --name Test_Runner --target-org str-scratch +sf apex run test --target-org str-scratch --wait 10 --code-coverage --result-format human +``` + +Or run tests via npm: + +```bash +npm test +``` + +### Continuous integration + +GitHub Actions runs Apex tests on pull requests and pushes to `master`. The workflow requires a repository secret: + +- **`DEVHUB_AUTH_URL`** β€” SFDX auth URL for the Dev Hub org (used to create ephemeral scratch orgs). + +If the secret is not configured, the CI job will fail until it is set. + ### Releasing a new version -Make your updates, release a new version: +Make your updates, then create and promote a package version: -- open `sfdx-project.json` - - increment `versionName` and `versionNumber` respectively - - save -- `sfdx force:package:version:create -p TestRunner -d force-app -x --wait 10 -v CCC-SFDC-Production` +1. Open `sfdx-project.json` and increment `versionName` and `versionNumber` as needed. +2. Create a package version: -### Promote +```bash +sf package version create --package TestRunner --path force-app --installation-key-bypass --wait 10 --target-dev-hub +``` + +3. Promote when ready: -Once you are ready to release the updated package, use the 04t\* Id from the new release to promote: +```bash +sf package version promote --package 04t... --target-dev-hub +``` -- `sfdx force:package:version:promote -p 04t\*` -- update `README.mdd` install step with the new install URL +4. Update the install URL in this README with the new `04t` Id from the promoted version. diff --git a/config/project-scratch-def.json b/config/project-scratch-def.json index 58227ce..b0fa751 100644 --- a/config/project-scratch-def.json +++ b/config/project-scratch-def.json @@ -1,10 +1,10 @@ { - "orgName": "charlesjonas Company", + "orgName": "Scheduled Test Runner", "edition": "Developer", - "features": [], + "hasSampleData": false, "settings": { - "orgPreferenceSettings": { - "s1DesktopEnabled": true + "lightningExperienceSettings": { + "enableS1DesktopEnabled": true } } } diff --git a/force-app/main/default/classes/TestRunCleanup.cls-meta.xml b/force-app/main/default/classes/TestRunCleanup.cls-meta.xml index db9bf8c..b1a915c 100644 --- a/force-app/main/default/classes/TestRunCleanup.cls-meta.xml +++ b/force-app/main/default/classes/TestRunCleanup.cls-meta.xml @@ -1,5 +1,5 @@ - 48.0 + 59.0 Active diff --git a/force-app/main/default/classes/TestRunCleanupTest.cls-meta.xml b/force-app/main/default/classes/TestRunCleanupTest.cls-meta.xml index db9bf8c..b1a915c 100644 --- a/force-app/main/default/classes/TestRunCleanupTest.cls-meta.xml +++ b/force-app/main/default/classes/TestRunCleanupTest.cls-meta.xml @@ -1,5 +1,5 @@ - 48.0 + 59.0 Active diff --git a/force-app/main/default/classes/TestRunProcessor.cls-meta.xml b/force-app/main/default/classes/TestRunProcessor.cls-meta.xml index db9bf8c..b1a915c 100644 --- a/force-app/main/default/classes/TestRunProcessor.cls-meta.xml +++ b/force-app/main/default/classes/TestRunProcessor.cls-meta.xml @@ -1,5 +1,5 @@ - 48.0 + 59.0 Active diff --git a/force-app/main/default/classes/TestRunProcessorTest.cls-meta.xml b/force-app/main/default/classes/TestRunProcessorTest.cls-meta.xml index db9bf8c..b1a915c 100644 --- a/force-app/main/default/classes/TestRunProcessorTest.cls-meta.xml +++ b/force-app/main/default/classes/TestRunProcessorTest.cls-meta.xml @@ -1,5 +1,5 @@ - 48.0 + 59.0 Active diff --git a/force-app/main/default/classes/TestRunScheduler.cls-meta.xml b/force-app/main/default/classes/TestRunScheduler.cls-meta.xml index db9bf8c..b1a915c 100644 --- a/force-app/main/default/classes/TestRunScheduler.cls-meta.xml +++ b/force-app/main/default/classes/TestRunScheduler.cls-meta.xml @@ -1,5 +1,5 @@ - 48.0 + 59.0 Active diff --git a/force-app/main/default/classes/TestRunSchedulerTest.cls-meta.xml b/force-app/main/default/classes/TestRunSchedulerTest.cls-meta.xml index db9bf8c..b1a915c 100644 --- a/force-app/main/default/classes/TestRunSchedulerTest.cls-meta.xml +++ b/force-app/main/default/classes/TestRunSchedulerTest.cls-meta.xml @@ -1,5 +1,5 @@ - 48.0 + 59.0 Active diff --git a/notifications/workflow-email/classes/TestRunEmailController.cls-meta.xml b/notifications/workflow-email/classes/TestRunEmailController.cls-meta.xml index 8e4d11f..b1a915c 100644 --- a/notifications/workflow-email/classes/TestRunEmailController.cls-meta.xml +++ b/notifications/workflow-email/classes/TestRunEmailController.cls-meta.xml @@ -1,5 +1,5 @@ - 49.0 + 59.0 Active diff --git a/notifications/workflow-email/classes/TestRunEmailControllerTest.cls-meta.xml b/notifications/workflow-email/classes/TestRunEmailControllerTest.cls-meta.xml index db9bf8c..b1a915c 100644 --- a/notifications/workflow-email/classes/TestRunEmailControllerTest.cls-meta.xml +++ b/notifications/workflow-email/classes/TestRunEmailControllerTest.cls-meta.xml @@ -1,5 +1,5 @@ - 48.0 + 59.0 Active diff --git a/notifications/workflow-email/components/FailureTable.component-meta.xml b/notifications/workflow-email/components/FailureTable.component-meta.xml index e75ebab..5e7410e 100644 --- a/notifications/workflow-email/components/FailureTable.component-meta.xml +++ b/notifications/workflow-email/components/FailureTable.component-meta.xml @@ -1,5 +1,5 @@ - 49.0 + 59.0 \ No newline at end of file diff --git a/notifications/workflow-email/email/Test_Runner_Email_Templates/Test_Run_VF_Notification.email-meta.xml b/notifications/workflow-email/email/Test_Runner_Email_Templates/Test_Run_VF_Notification.email-meta.xml index 7da6ca2..a19f70b 100644 --- a/notifications/workflow-email/email/Test_Runner_Email_Templates/Test_Run_VF_Notification.email-meta.xml +++ b/notifications/workflow-email/email/Test_Runner_Email_Templates/Test_Run_VF_Notification.email-meta.xml @@ -1,6 +1,6 @@ - 49.0 + 59.0 true Visual Force Email Template for Test Runner UTF-8 diff --git a/package.json b/package.json index e778f62..be2c8f6 100644 --- a/package.json +++ b/package.json @@ -4,14 +4,14 @@ "description": "A library for scheduling production test runs", "main": "index.js", "scripts": { - "test": "echo \"Error: no test specified\" && exit 1" + "test": "sf apex run test --wait 10 --result-format human --code-coverage" }, "repository": { "type": "git", "url": "git+https://github.com/ChuckJonas/ScheduledTestRunner.git" }, "author": "", - "license": "ISC", + "license": "MIT", "bugs": { "url": "https://github.com/ChuckJonas/ScheduledTestRunner/issues" }, diff --git a/sfdx-project.json b/sfdx-project.json index 1f65416..e0f0e33 100644 --- a/sfdx-project.json +++ b/sfdx-project.json @@ -4,7 +4,7 @@ "path": "force-app", "default": true, "package": "TestRunner", - "versionName": "ver 0.8", + "versionName": "ver 0.1.7", "versionNumber": "0.1.7.NEXT" }, { @@ -13,7 +13,7 @@ ], "namespace": "", "sfdcLoginUrl": "https://login.salesforce.com", - "sourceApiVersion": "45.0", + "sourceApiVersion": "59.0", "packageAliases": { "TestRunner": "0Ho1C000000002bSAA", "TestRunner@0.1.0-1": "04t1C000000goM5QAI",