CRM-20351 ACL: Don't repeat stuff in the page run() method
authorAndrew Hunt <andrew@aghstrategies.com>
Tue, 28 Mar 2017 19:37:36 +0000 (15:37 -0400)
committerdeb.monish <monish.deb@jmaconsulting.biz>
Fri, 7 Jul 2017 20:52:41 +0000 (02:22 +0530)
12 files changed:
CRM/ACL/Page/ACL.php
CRM/ACL/Page/ACLBasic.php
CRM/ACL/Page/EntityRole.php
CRM/Contact/Page/DedupeRules.php
CRM/Contribute/Page/ManagePremiums.php
CRM/Core/Page/Basic.php
CRM/Financial/Page/FinancialAccount.php
CRM/Financial/Page/FinancialBatch.php
CRM/Financial/Page/FinancialType.php
CRM/Member/Page/MembershipStatus.php
CRM/PCP/Page/PCP.php
tests/phpunit/CRM/Core/Page/HookTest.php [new file with mode: 0644]

index b8c45390fe7f2adc837f49526804098292a62d09..a17e4bc09012055f3bf7d7d63dca15cd33f3c0a9 100644 (file)
@@ -90,23 +90,9 @@ class CRM_ACL_Page_ACL extends CRM_Core_Page_Basic {
   /**
    * Run the page.
    *
-   * This method is called after the page is created. It checks for the
-   * type of action and executes that action.
-   * Finally it calls the parent's run method.
+   * Set the breadcrumb before beginning the standard page run.
    */
   public function run() {
-    // get the requested action
-    $action = CRM_Utils_Request::retrieve('action', 'String',
-      // default to 'browse'
-      $this, FALSE, 'browse'
-    );
-
-    // assign vars to templates
-    $this->assign('action', $action);
-    $id = CRM_Utils_Request::retrieve('id', 'Positive',
-      $this, FALSE, 0
-    );
-
     // set breadcrumb to append to admin/access
     $breadCrumb = array(
       array(
@@ -117,24 +103,6 @@ class CRM_ACL_Page_ACL extends CRM_Core_Page_Basic {
       ),
     );
     CRM_Utils_System::appendBreadCrumb($breadCrumb);
-    // what action to take ?
-    if ($action & (CRM_Core_Action::ADD | CRM_Core_Action::DELETE)) {
-      $this->edit($action, $id);
-    }
-
-    if ($action & (CRM_Core_Action::UPDATE)) {
-      $this->edit($action, $id);
-
-      if (isset($id)) {
-        $aclName = CRM_Core_DAO::getFieldValue('CRM_ACL_DAO_ACL', $id);
-        CRM_Utils_System::setTitle(ts('Edit ACL -  %1', array(1 => $aclName)));
-      }
-    }
-
-    // finally browse the acl's
-    if ($action & CRM_Core_Action::BROWSE) {
-      $this->browse();
-    }
 
     // parent run
     return parent::run();
@@ -193,22 +161,22 @@ ORDER BY entity_id
 
       switch ($acl[$dao->id]['object_table']) {
         case 'civicrm_saved_search':
-          $acl[$dao->id]['object'] = $group[$acl[$dao->id]['object_id']];
+          $acl[$dao->id]['object'] = CRM_Utils_Array::value($acl[$dao->id]['object_id'], $group);
           $acl[$dao->id]['object_name'] = ts('Group');
           break;
 
         case 'civicrm_uf_group':
-          $acl[$dao->id]['object'] = $ufGroup[$acl[$dao->id]['object_id']];
+          $acl[$dao->id]['object'] = CRM_Utils_Array::value($acl[$dao->id]['object_id'], $ufGroup);
           $acl[$dao->id]['object_name'] = ts('Profile');
           break;
 
         case 'civicrm_custom_group':
-          $acl[$dao->id]['object'] = $customGroup[$acl[$dao->id]['object_id']];
+          $acl[$dao->id]['object'] = CRM_Utils_Array::value($acl[$dao->id]['object_id'], $customGroup);
           $acl[$dao->id]['object_name'] = ts('Custom Group');
           break;
 
         case 'civicrm_event':
-          $acl[$dao->id]['object'] = $event[$acl[$dao->id]['object_id']];
+          $acl[$dao->id]['object'] = CRM_Utils_Array::value($acl[$dao->id]['object_id'], $event);
           $acl[$dao->id]['object_name'] = ts('Event');
           break;
       }
@@ -269,4 +237,26 @@ ORDER BY entity_id
     return 'civicrm/acl';
   }
 
+  /**
+   * Edit an ACL.
+   *
+   * @param int $mode
+   *   What mode for the form ?.
+   * @param int $id
+   *   Id of the entity (for update, view operations).
+   * @param bool $imageUpload
+   *   Not used in this case, but extended from CRM_Core_Page_Basic.
+   * @param bool $pushUserContext
+   *   Not used in this case, but extended from CRM_Core_Page_Basic.
+   */
+  public function edit($mode, $id = NULL, $imageUpload = FALSE, $pushUserContext = TRUE) {
+    if ($mode & (CRM_Core_Action::UPDATE)) {
+      if (isset($id)) {
+        $aclName = CRM_Core_DAO::getFieldValue('CRM_ACL_DAO_ACL', $id);
+        CRM_Utils_System::setTitle(ts('Edit ACL &ndash; %1', array(1 => $aclName)));
+      }
+    }
+    parent::edit($mode, $id, $imageUpload, $pushUserContext);
+  }
+
 }
index c536917e2b1a6636722e74a2a47af4d729faf2ca..758705e31494feebbf8e3e0ea713e2d6b95cc305 100644 (file)
@@ -83,17 +83,7 @@ class CRM_ACL_Page_ACLBasic extends CRM_Core_Page_Basic {
    * Finally it calls the parent's run method.
    */
   public function run() {
-    // get the requested action
-    $action = CRM_Utils_Request::retrieve('action', 'String',
-      // default to 'browse'
-      $this, FALSE, 'browse'
-    );
-
-    // assign vars to templates
-    $this->assign('action', $action);
-    $id = CRM_Utils_Request::retrieve('id', 'Positive',
-      $this, FALSE, 0
-    );
+    $id = $this->getIdAndAction();
 
     // set breadcrumb to append to admin/access
     $breadCrumb = array(
@@ -105,15 +95,15 @@ class CRM_ACL_Page_ACLBasic extends CRM_Core_Page_Basic {
     CRM_Utils_System::appendBreadCrumb($breadCrumb);
 
     // what action to take ?
-    if ($action & (CRM_Core_Action::UPDATE | CRM_Core_Action::ADD | CRM_Core_Action::DELETE)) {
-      $this->edit($action, $id);
+    if ($this->_action & (CRM_Core_Action::UPDATE | CRM_Core_Action::ADD | CRM_Core_Action::DELETE)) {
+      $this->edit($this->_action, $id);
     }
 
     // finally browse the acl's
     $this->browse();
 
-    // parent run
-    return parent::run();
+    // This replaces parent run, but do parent's parent run
+    return CRM_Core_Page::run();
   }
 
   /**
index 32a67e4bc555496d4f99480f9af7f649bd173415..7a1b510a6ec2c26baf3e129d8663c32b11d16c24 100644 (file)
@@ -95,17 +95,7 @@ class CRM_ACL_Page_EntityRole extends CRM_Core_Page_Basic {
    * Finally it calls the parent's run method.
    */
   public function run() {
-    // get the requested action
-    $action = CRM_Utils_Request::retrieve('action', 'String',
-      // default to 'browse'
-      $this, FALSE, 'browse'
-    );
-
-    // assign vars to templates
-    $this->assign('action', $action);
-    $id = CRM_Utils_Request::retrieve('id', 'Positive',
-      $this, FALSE, 0
-    );
+    $id = $this->getIdAndAction();
 
     // set breadcrumb to append to admin/access
     $breadCrumb = array(
@@ -120,21 +110,21 @@ class CRM_ACL_Page_EntityRole extends CRM_Core_Page_Basic {
     CRM_Utils_System::setTitle(ts('Assign Users to Roles'));
 
     // what action to take ?
-    if ($action & (CRM_Core_Action::UPDATE | CRM_Core_Action::ADD | CRM_Core_Action::DELETE)) {
-      $this->edit($action, $id);
+    if ($this->_action & (CRM_Core_Action::UPDATE | CRM_Core_Action::ADD | CRM_Core_Action::DELETE)) {
+      $this->edit($this->_action, $id);
     }
 
     // reset cache if enabled/disabled
-    if ($action & (CRM_Core_Action::DISABLE | CRM_Core_Action::ENABLE)) {
+    if ($this->_action & (CRM_Core_Action::DISABLE | CRM_Core_Action::ENABLE)) {
       CRM_ACL_BAO_Cache::resetCache();
     }
 
     // finally browse the acl's
-    if ($action & CRM_Core_Action::BROWSE) {
+    if ($this->_action & CRM_Core_Action::BROWSE) {
     }
 
-    // parent run
-    return parent::run();
+    // This replaces parent run, but do parent's parent run
+    return CRM_Core_Page::run();
   }
 
   /**
index 151dcdf58eaafe757b6dc2bbd4e37b7c14419f5f..01d420d714d9913088aa44234fce7674b709e67e 100644 (file)
@@ -99,12 +99,7 @@ class CRM_Contact_Page_DedupeRules extends CRM_Core_Page_Basic {
    * method.
    */
   public function run() {
-    // get the requested action, default to 'browse'
-    $action = CRM_Utils_Request::retrieve('action', 'String', $this, FALSE, 'browse');
-
-    // assign vars to templates
-    $this->assign('action', $action);
-    $id = CRM_Utils_Request::retrieve('id', 'Positive', $this, FALSE, 0);
+    $id = $this->getIdAndAction();
 
     $context = CRM_Utils_Request::retrieve('context', 'String', $this, FALSE);
     if ($context == 'nonDupe') {
@@ -116,18 +111,18 @@ class CRM_Contact_Page_DedupeRules extends CRM_Core_Page_Basic {
     $this->assign('hasperm_merge_duplicate_contacts', CRM_Core_Permission::check('merge duplicate contacts'));
 
     // which action to take?
-    if ($action & (CRM_Core_Action::UPDATE | CRM_Core_Action::ADD)) {
-      $this->edit($action, $id);
+    if ($this->_action & (CRM_Core_Action::UPDATE | CRM_Core_Action::ADD)) {
+      $this->edit($this->_action, $id);
     }
-    if ($action & CRM_Core_Action::DELETE) {
+    if ($this->_action & CRM_Core_Action::DELETE) {
       $this->delete($id);
     }
 
     // browse the rules
     $this->browse();
 
-    // parent run
-    return parent::run();
+    // This replaces parent run, but do parent's parent run
+    return CRM_Core_Page::run();
   }
 
   /**
index a5dc049dcbbc1c1011a8143681295e38da4bdbd4..69d204e9aa11c8f4dca0002754d4dc6b81aa52ac 100644 (file)
@@ -105,28 +105,17 @@ class CRM_Contribute_Page_ManagePremiums extends CRM_Core_Page_Basic {
    * Finally it calls the parent's run method.
    */
   public function run() {
-
-    // get the requested action
-    $action = CRM_Utils_Request::retrieve('action', 'String',
-      // default to 'browse'
-      $this, FALSE, 'browse'
-    );
-
-    // assign vars to templates
-    $this->assign('action', $action);
-    $id = CRM_Utils_Request::retrieve('id', 'Positive',
-      $this, FALSE, 0
-    );
+    $id = $this->getIdAndAction();
 
     // what action to take ?
-    if ($action & (CRM_Core_Action::UPDATE | CRM_Core_Action::ADD | CRM_Core_Action::PREVIEW)) {
-      $this->edit($action, $id, TRUE);
+    if ($this->_action & (CRM_Core_Action::UPDATE | CRM_Core_Action::ADD | CRM_Core_Action::PREVIEW)) {
+      $this->edit($this->_action, $id, TRUE);
     }
     // finally browse the custom groups
     $this->browse();
 
     // parent run
-    return parent::run();
+    return CRM_Core_Page::run();
   }
 
   /**
index 069ba23782e6bede213cb405a7dff5261e3bec0d..b0b7934adbdbd372ca4961c10c4c7e5af3390dff 100644 (file)
@@ -141,19 +141,7 @@ abstract class CRM_Core_Page_Basic extends CRM_Core_Page {
     $sort = ($n > 2) ? func_get_arg(2) : NULL;
     // what action do we want to perform ? (store it for smarty too.. :)
 
-    $this->_action = CRM_Utils_Request::retrieve('action', 'String', $this, FALSE, 'browse');
-    $this->assign('action', $this->_action);
-
-    // get 'id' if present
-    $id = CRM_Utils_Request::retrieve('id', 'Positive', $this, FALSE, 0);
-
-    require_once str_replace('_', DIRECTORY_SEPARATOR, $this->getBAOName()) . ".php";
-
-    if ($id) {
-      if (!$this->checkPermission($id, NULL)) {
-        CRM_Core_Error::fatal(ts('You do not have permission to make changes to the record'));
-      }
-    }
+    $id = $this->getIdAndAction();
 
     if ($this->_action & (CRM_Core_Action::VIEW |
         CRM_Core_Action::ADD |
@@ -175,6 +163,33 @@ abstract class CRM_Core_Page_Basic extends CRM_Core_Page {
     return parent::run();
   }
 
+  /**
+   * Retrieve the action and ID from the request.
+   *
+   * Action is assigned to the template while we're at it.  This is pulled from
+   * the `run()` method above.
+   *
+   * @return int
+   *   The ID if present, or 0.
+   */
+  public function getIdAndAction() {
+    $this->_action = CRM_Utils_Request::retrieve('action', 'String', $this, FALSE, 'browse');
+    $this->assign('action', $this->_action);
+
+    // get 'id' if present
+    $id = CRM_Utils_Request::retrieve('id', 'Positive', $this, FALSE, 0);
+
+    require_once str_replace('_', DIRECTORY_SEPARATOR, $this->getBAOName()) . ".php";
+
+    if ($id) {
+      if (!$this->checkPermission($id, NULL)) {
+        CRM_Core_Error::fatal(ts('You do not have permission to make changes to the record'));
+      }
+    }
+
+    return $id;
+  }
+
   /**
    * @return string
    */
@@ -288,11 +303,10 @@ abstract class CRM_Core_Page_Basic extends CRM_Core_Page {
     $hasDelete = $hasDisable = TRUE;
 
     if (!empty($values['name']) && in_array($values['name'], array(
-        'encounter_medium',
-        'case_type',
-        'case_status',
-      ))
-    ) {
+      'encounter_medium',
+      'case_type',
+      'case_status',
+    ))) {
       static $caseCount = NULL;
       if (!isset($caseCount)) {
         $caseCount = CRM_Case_BAO_Case::caseCount(NULL, FALSE);
index 3bfe8862a2b6c3d9ee8b2ea2d36b596978052289..0d4718b6e2883fcd7a447f70f42e04be935b436b 100644 (file)
@@ -90,30 +90,6 @@ class CRM_Financial_Page_FinancialAccount extends CRM_Core_Page_Basic {
     return self::$_links;
   }
 
-  /**
-   * Run the page.
-   *
-   * This method is called after the page is created. It checks for the
-   * type of action and executes that action.
-   * Finally it calls the parent's run method.
-   */
-  public function run() {
-    // get the requested action
-    $action = CRM_Utils_Request::retrieve('action', 'String', $this, FALSE, 'browse'); // default to 'browse'
-
-    // assign vars to templates
-    $this->assign('action', $action);
-    $id = CRM_Utils_Request::retrieve('id', 'Positive', $this, FALSE, 0);
-
-    // what action to take ?
-    if ($action & (CRM_Core_Action::UPDATE | CRM_Core_Action::ADD)) {
-      $this->edit($action, $id);
-    }
-
-    // parent run
-    return parent::run();
-  }
-
   /**
    * Browse all custom data groups.
    */
index 17b9cf84b7ef00c1d862a91a1855251e0c5646f3..6ce5833a5fc23eaeb9dbc98465cd82e193814521 100644 (file)
@@ -76,21 +76,20 @@ class CRM_Financial_Page_FinancialBatch extends CRM_Core_Page_Basic {
   public function run() {
     $context = CRM_Utils_Request::retrieve('context', 'String', $this);
     $this->set("context", $context);
-    // assign vars to templates
-    $id = CRM_Utils_Request::retrieve('id', 'Positive', $this, FALSE, 0);
-    $action = CRM_Utils_Request::retrieve('action', 'String', $this, FALSE, 'browse'); // default to 'browse'
+
+    $id = $this->getIdAndAction();
 
     // what action to take ?
-    if ($action & (CRM_Core_Action::UPDATE |
+    if ($this->_action & (CRM_Core_Action::UPDATE |
         CRM_Core_Action::ADD |
         CRM_Core_Action::CLOSE |
         CRM_Core_Action::REOPEN |
         CRM_Core_Action::EXPORT)
     ) {
-      $this->edit($action, $id);
+      $this->edit($this->_action, $id);
     }
     // parent run
-    return parent::run();
+    return CRM_Core_Page::run();
   }
 
 
index 7d3ff037d8020915e8df98dc381d619ab86c4b8c..53d31b84b674e10358f93af7889d12e866cd350d 100644 (file)
@@ -96,30 +96,6 @@ class CRM_Financial_Page_FinancialType extends CRM_Core_Page_Basic {
     return self::$_links;
   }
 
-  /**
-   * Run the page.
-   *
-   * This method is called after the page is created. It checks for the
-   * type of action and executes that action.
-   * Finally it calls the parent's run method.
-   */
-  public function run() {
-    // get the requested action
-    $action = CRM_Utils_Request::retrieve('action', 'String', $this, FALSE, 'browse'); // default to 'browse'
-
-    // assign vars to templates
-    $this->assign('action', $action);
-    $id = CRM_Utils_Request::retrieve('id', 'Positive', $this, FALSE, 0);
-
-    // what action to take ?
-    if ($action & (CRM_Core_Action::UPDATE | CRM_Core_Action::ADD)) {
-      $this->edit($action, $id);
-    }
-
-    // parent run
-    return parent::run();
-  }
-
   /**
    * Browse all financial types.
    */
index 415aae7bcf74a50dec85d1cc58a5ff1ec833098d..aa9732200190165d8ba19654cf1eb8894e803fa0 100644 (file)
@@ -93,39 +93,6 @@ class CRM_Member_Page_MembershipStatus extends CRM_Core_Page_Basic {
     return self::$_links;
   }
 
-  /**
-   * Run the page.
-   *
-   * This method is called after the page is created. It checks for the
-   * type of action and executes that action.
-   * Finally it calls the parent's run method.
-   *
-   * @return void
-   */
-  public function run() {
-    // get the requested action
-    $action = CRM_Utils_Request::retrieve('action', 'String',
-      // default to 'browse'
-      $this, FALSE, 'browse'
-    );
-
-    // assign vars to templates
-    $this->assign('action', $action);
-    $id = CRM_Utils_Request::retrieve('id', 'Positive',
-      $this, FALSE, 0
-    );
-
-    // what action to take ?
-    if ($action & (CRM_Core_Action::UPDATE | CRM_Core_Action::ADD)) {
-      $this->edit($action, $id);
-    }
-    // finally browse the custom groups
-    $this->browse();
-
-    // parent run
-    return parent::run();
-  }
-
   /**
    * Browse all custom data groups.
    *
index e090829e0e06ff9daa37d3de6144a5f8184fef03..c7255ffb5fc35f66b3d5f1d9d45d5da787b36582 100644 (file)
@@ -121,25 +121,19 @@ class CRM_PCP_Page_PCP extends CRM_Core_Page_Basic {
    * @return void
    */
   public function run() {
-    // get the requested action
-    $action = CRM_Utils_Request::retrieve('action', 'String',
-      $this, FALSE,
-      'browse'
-    );
-    if ($action & CRM_Core_Action::REVERT) {
-      $id = CRM_Utils_Request::retrieve('id', 'Positive', $this, FALSE);
+    $id = $this->getIdAndAction();
+
+    if ($this->_action & CRM_Core_Action::REVERT) {
       CRM_PCP_BAO_PCP::setIsActive($id, 0);
       $session = CRM_Core_Session::singleton();
       $session->pushUserContext(CRM_Utils_System::url(CRM_Utils_System::currentPath(), 'reset=1'));
     }
-    elseif ($action & CRM_Core_Action::RENEW) {
-      $id = CRM_Utils_Request::retrieve('id', 'Positive', $this, FALSE);
+    elseif ($this->_action & CRM_Core_Action::RENEW) {
       CRM_PCP_BAO_PCP::setIsActive($id, 1);
       $session = CRM_Core_Session::singleton();
       $session->pushUserContext(CRM_Utils_System::url(CRM_Utils_System::currentPath(), 'reset=1'));
     }
-    elseif ($action & CRM_Core_Action::DELETE) {
-      $id = CRM_Utils_Request::retrieve('id', 'Positive', $this, FALSE);
+    elseif ($this->_action & CRM_Core_Action::DELETE) {
       $session = CRM_Core_Session::singleton();
       $session->pushUserContext(CRM_Utils_System::url(CRM_Utils_System::currentPath(), 'reset=1&action=browse'));
       $controller = new CRM_Core_Controller_Simple('CRM_PCP_Form_PCP',
@@ -156,7 +150,7 @@ class CRM_PCP_Page_PCP extends CRM_Core_Page_Basic {
     $this->browse();
 
     // parent run
-    parent::run();
+    CRM_Core_Page::run();
   }
 
   /**
diff --git a/tests/phpunit/CRM/Core/Page/HookTest.php b/tests/phpunit/CRM/Core/Page/HookTest.php
new file mode 100644 (file)
index 0000000..ce90577
--- /dev/null
@@ -0,0 +1,160 @@
+<?php
+
+/**
+ * Test that page hooks only get invoked once per page run.
+ */
+class CRM_Core_Page_HookTest extends CiviUnitTestCase {
+  public $DBResetRequired = TRUE;
+
+  /**
+   * The list of classes extending CRM_Core_Page_Basic: the ones to try the
+   * `run()` method on.
+   *
+   * @var array
+   */
+  public $basicPages = array();
+
+  /**
+   * A place to hold the counts of hook invocations.
+   *
+   * @var array
+   */
+  public $hookCount = array();
+
+  /**
+   * Classes that should be skipped
+   *
+   * The main reason is that they look for URL parameters that we don't know to
+   * provide.
+   *
+   * TODO: track down what's needed (in a way that we can be confident for
+   * testing) and quit skipping them.
+   *
+   * @var array
+   */
+  public $skip = array(
+    'CRM_Contact_Page_DedupeFind',
+    'CRM_Mailing_Page_Report',
+    'CRM_Financial_Page_BatchTransaction',
+    'CRM_Admin_Page_PreferencesDate',
+    'CRM_Admin_Page_Extensions',
+    'CRM_Admin_Page_PaymentProcessor',
+    'CRM_Admin_Page_LabelFormats',
+    // This is a page with no corresponding form:
+    'CRM_Admin_Page_EventTemplate',
+  );
+
+  /**
+   * Set up the list of pages to evaluate by going through the menu.
+   */
+  public function setUp() {
+    // Get all of the menu items in CiviCRM.
+    $items = CRM_Core_Menu::items(TRUE);
+    // Check if they extend the class we care about; test if needed.
+    foreach ($items as $item) {
+      $class = is_array($item['page_callback']) ? $item['page_callback'][0] : $item['page_callback'];
+      if (in_array($class, $this->skip)) {
+        continue;
+      }
+      if (is_subclass_of($class, 'CRM_Core_Page_Basic')) {
+        $this->basicPages[] = $class;
+      }
+    }
+    parent::setUp();
+  }
+
+  /**
+   * Make sure form hooks are only invoked once.
+   */
+  public function testFormsCallBuildFormOnce() {
+    CRM_Utils_Hook_UnitTests::singleton()->setHook('civicrm_buildForm', array($this, 'onBuildForm'));
+    CRM_Utils_Hook_UnitTests::singleton()->setHook('civicrm_preProcess', array($this, 'onPreProcess'));
+    $_REQUEST = array('action' => 'add');
+    foreach ($this->basicPages as $pageName) {
+      // Reset the counters
+      $this->hookCount = array(
+        'buildForm' => array(),
+        'preProcess' => array(),
+      );
+      $page = new $pageName();
+      ob_start();
+      $page->run();
+      ob_end_clean();
+      $this->runTestAssert($pageName);
+    }
+  }
+
+  /**
+   * Go through the record of hook invocation and make sure that each hook has
+   * run once and no more than once per class.
+   *
+   * @param string $pageName
+   *   The page/form evaluated.
+   */
+  private function runTestAssert($pageName) {
+    foreach ($this->hookCount as $hook => $hookUsage) {
+      $ran = FALSE;
+      foreach ($hookUsage as $class => $count) {
+        $ran = TRUE;
+        // The hook should have run once and only once.
+        $this->assertEquals(1, $count, "Checking $pageName: $hook invoked multiple times with $class");
+      }
+      $this->assertTrue($ran, "$hook never invoked for $pageName");
+    }
+  }
+
+  /**
+   * Make sure pageRun hook is only invoked once.
+   */
+  public function testPagesCallPageRunOnce() {
+    CRM_Utils_Hook_UnitTests::singleton()->setHook('civicrm_pageRun', array($this, 'onPageRun'));
+    $_REQUEST = array('action' => 'browse');
+    foreach ($this->basicPages as $pageName) {
+      // Reset the counters
+      $this->hookCount = array('pageRun' => array());
+      $page = new $pageName();
+      ob_start();
+      $page->run();
+      ob_end_clean();
+      $this->runTestAssert($pageName);
+    }
+  }
+
+  /**
+   * Implements hook_civicrm_buildForm().
+   *
+   * Increment the count of uses of this hook per formName.
+   */
+  public function onBuildForm($formName, &$form) {
+    $this->incrementHookCount('buildForm', $formName);
+  }
+
+  public function onPreProcess($formName, &$form) {
+    $this->incrementHookCount('preProcess', $formName);
+  }
+
+  /**
+   * Implements hook_civicrm_pageRun().
+   *
+   * Increment the count of uses of this hook per page class.
+   */
+  public function onPageRun(&$page) {
+    $this->incrementHookCount('pageRun', get_class($page));
+  }
+
+  /**
+   * Increment the count of uses of a hook in a class.
+   *
+   * @param string $hook
+   *   The hook being used.
+   * @param string $class
+   *   The class name of the page or form.
+   */
+  private function incrementHookCount($hook, $class) {
+    if (empty($this->hookCount[$hook][$class])) {
+      $this->hookCount[$hook][$class] = 0;
+    }
+    $this->hookCount[$hook][$class]++;
+  }
+
+}