Test all Drupal Routes

Created on 19 January 2024, 5 months ago
Updated 30 January 2024, 5 months ago

Problem/Motivation

Just ran across your project we have been using cypress in Drupal for a while and I would just like to share what I think has been our most important tests. We use a module called https://www.drupal.org/project/routes_list โ†’ with a patch to convert the route list to json. We then save that to a "routes-list.json fixture file. And have cypress visit all the routes, (we have to exclude some routes like autocomplete and ajax paths.) The test looks something like what I am going to paste below.

Why has this been so valuable? Well as cypress visits all the module routes, it looks for drupal error messages on each url. And typically will catch poorly updated modules and bugs introduced such as php warnings or deprecations added.

/// <reference types="Cypress" />


// https://www.drupal.org/project/routes_list to find routes to test
var urls = [];

// urls to ignore from automated testing
// some of these routes are POST routes or other URLS that cant be visited
import { urlsToIgnore } from "../../fixtures/urls-to-ignore.js";

var userPass;

function removeFromArray(original, remove) {
  return original.filter((value) => !remove.includes(value));
}
// import the fixture with the data for new tests
import { data } from "../../fixtures/routes-list.json";

// convert the routes object to an array and filter out the routes we don't want to test
var routesList = data.routes;
// log type of routesList
Object.keys(routesList).forEach(function (key) {
  let thisItem = routesList[key];

  if (thisItem && thisItem.hasOwnProperty("path")) {
    // cy.log("routes.data.routes", thisItem);
    if (thisItem.path.hasOwnProperty("#context")) {
      if (thisItem.path["#context"].hasOwnProperty("path")) {
        let thisPath = thisItem.path["#context"].path;
        if (thisPath && !thisPath.includes("{")) {
          // debugger
          urls.push(thisPath);
        }
      }
    }
  }
});
// Remove ones that we don't want to check
urls = removeFromArray(urls, urlsToIgnore);
var urlsLength = urls.length;

// https://www.drupal.org/project/routes_list to find routes to test
describe("All Drupal Module Pages load", () => {
  beforeEach(() => {});

  before(() => {});

  // loop over all URLS and create a test for each
  // https://docs.cypress.io/api/commands/each.html#Syntax

  // create a test for each item imported from the fixture
  urls.forEach((route, index) => {
    it(`${baseIndex + index + 1}/${urlsLength} ${route} `, () => {
      const userPass = Cypress.env("cyAdminPassword") || "admin";
      cy.login("testadminuser", userPass);
      // visit route but don't fail on client side errors
      cy.visit(route, { failOnStatusCode: false });
      // Check there are no Drupal error messages
      cy.get("body").then(($body) => {
        cy.checkBodyForErrors($body);
      });
    });
  });
});

โœจ Feature request
Status

Active

Version

1.2

Component

Code

Created by

๐Ÿ‡บ๐Ÿ‡ธUnited States NicholasS

Live updates comments and jobs are added and updated live.
Sign in to follow issues

Comments & Activities

  • Issue created by @NicholasS
  • ๐Ÿ‡บ๐Ÿ‡ธUnited States NicholasS

    Here is our route list patch to convert it to json

  • ๐Ÿ‡บ๐Ÿ‡ธUnited States aangel

    Nice. I like it. Definitely going in!

  • ๐Ÿ‡บ๐Ÿ‡ธUnited States NicholasS

    Also here is the way I use cypress to visit all the CMS routes, and look for PHP errors, I hobbled this together myself so I am sure it could be improved, but I wanted cypress to find all the Drupal CMS error messages (watchdog messages) as well as find PHP white screens of death that had stack traces.

    // Pass in the pages HTML and look for Drupal or PHP errors
    Cypress.Commands.add("checkBodyForErrors", ($body) => {
        function checkIfShouldIgnore(msg) {
            // This function will check to see if the error message is a known one and should be ignored or not
            if (msg.includes("There are security updates available for one or more of your modules or themes")) {
                // developers will be aware of outdated modules via other means
                return true;
            }
            if ( msg.includes("One or more problems were detected with your Drupal installation. Check the status report for more information.")) {
                // This just means to check the status page
                // was on admin/config
                return true;
            }
            if (msg.includes(`The config-set for the Solr server Solr could not be generated`)) {
                // fine on dev config set is on the server
                return true;
            }
            return false;
        }
        // see if any PHP errors happen only way I see to check is look for the
        // php error template HTML on the page
        if ($body.html().includes("</b> on line <b>")) {
            var ignore = false;
            if ($body.html().includes('Undefined index: search_views_query in') && $body.html().includes('core/modules/views/src/Views.php')) {
                ignore = true;
            }
            if ($body.html().includes('Invalid argument supplied for foreach()') && $body.html().includes('search_api_solr/src/Form/SolrFieldTypeForm.php')) {
                ignore = true;
            }
            if (!ignore) {
                throw new Error(`PHP issue found`);
            } else {
                return
            }
        }
        // Check if the body contains a drupal error class message
        if ($body.find(".messages--error").length > 0) {
            // There is either a single error or multiple error messages
            if ($body.find(".messages--error .messages__item").length > 0) {
                // if multiple they are put into an HTML list
                cy.get(".messages--error .messages__item").then(function($elems) {
                    // Loop over messages and evaluate
                    let i = 0;
                    for (i = 0; i < $elems.length; i++) {
                        let thisMsg = $elems[i].innerText.trim();
                        let shouldIgnore = checkIfShouldIgnore(thisMsg) ?? FALSE;
                        if (shouldIgnore) {
                            // ignore this message
                            cy.log("Ignoring:" + thisMsg);
                        } else {
                            // This is a problem message so trigger a fail
                            cy.log(thisMsg);
                            throw new Error(thisMsg);
                        }
                    }
                });
            } else {
                // else single
                cy.get(".messages--error").then(function($elem) {
                    let thisMsg = $elem.text().trim();
                    let shouldIgnore = checkIfShouldIgnore(thisMsg) ?? FALSE;
                    if (shouldIgnore) {
                        // ignore this message
                        cy.log("Ignoring:" + thisMsg);
                    } else {
                        // This is a problem message so trigger a fail
                        cy.log(thisMsg);
                        throw new Error(thisMsg);
                    }
                });
            }
        }
    });
    

    I use this function in all my tests really like

     it("Can visit /technology and view revision", () => {
            cy.login("simple", userPass);
            // used technology page because it changes daily so its guaranteed to have revisions
            cy.visit("/technology/about-us");
            cy.get("body").then(($body) => {
                cy.checkBodyForErrors($body);
            });
    .....
    

    The Fixtures file that I have to ignore URLs from the routes_list module looks somehting like... You end up having to add Ajax and autocomplete routes alot so not sure if there is a better way to filter them out.

    // file: fixtures/urls-to-ignore.js
    // export an array of urls to ignore
    // to import into multiple tests
    
    export const urlsToIgnore = [
      "/node",
      "/batch",
      "/machine_name/transliterate",
      "/admin/reports/status/run-cron",
      "/system/temporary",
      "/admin/appearance/install",
      '/admin/flush/cssjs',
      '/admin/flush',
      '/admin/flush/menu',
      '/admin/flush/rendercache',
      '/admin/flush/static-caches',
      '/admin/flush/twig',
      '/admin/flush/views',
      '/admin/flush/plugin',
      '/admin/flush/theme_rebuild',
      '/admin/reports/auditfiles/managednotused',
      '/admin/reports/auditfiles/mergefilereferences',
      '/admin/reports/auditfiles/notonserver',
      '/admin/reports/auditfiles/referencednotused',
      '/admin/reports/auditfiles/usednotmanaged',
      '/admin/reports/auditfiles/usednotreferenced',
      '/fullcalendar-view-event-add',
      '/fullcalendar-view-event-update',
      '/geocoder/api/geocode',
      '/geocoder/api/reverse_geocode',
      '/rest/session/token',
      "/admin/update/ready", // Does not load in acquia dev
      '/admin/reports/updates/check',
      "/admin/modules/install", //Acquia blocks this with git deploys
      '/admin/structure/context/groups/autocomplete',
    .......
    ];
    
  • ๐Ÿ‡บ๐Ÿ‡ธUnited States NicholasS

    Oh an I use node to login headlessly, fetch the routes list from Drupal before opening cypress. so that cypress can use the fixture file while testing, so that it generates a test for each CMS route.

    // File name: ./refresh-routes.js
    /* eslint-env node */
    
    // This script is used to refresh the routes list fixture file.
    // It should be run before running the tests.
    // pass it arguments like this:
    // node refresh-routes.js site=inet.ddev.site:9445 pass=admin
    
    const execSync = require("child_process").execSync;
    const axios = require("axios");
    const fs = require("fs");
    const https = require("https");
    
    const env = Object.create(process.env);
    
    process.argv.slice(2).forEach((arg) => {
      var [key, value] = arg.split("=");
      env[key] = value || true;
    });
    
    let siteDomain;
    var password;
    
    if (!env.site) {
      siteDomain = `https://yoursite.ddev.site`;
      password = "admin"
    } else {
      siteDomain = `https://${env.site}`;
      password = env.pass;
    }
    const baseUrl = siteDomain;
    
    
    async function loginAndSaveStatusReport() {
      try {
        // Drupal 9 site URL and credentials
        const siteUrl = baseUrl;
        const username = "admin";
    
        // At instance level
        const instance = axios.create({
          httpsAgent: new https.Agent({
            rejectUnauthorized: false
          })
        });
    
        // Perform login
        const loginResponse = await instance.post(
          `${siteUrl}/user/login?_format=json`,
          {
            name: username,
            pass: password,
          },
          {
            headers: {
              "Content-Type": "application/json",
            }
          }
        );
    
        if (loginResponse.status === 200 && loginResponse.data.current_user.uid) {
          console.log("Login successful");
          // Get routes list page
          const statusReportResponse = await instance.get(
            `${siteUrl}/admin/reports/routes-list-json`,
            {
              headers: {
                Cookie: `X-CSRF-Token=${
                  loginResponse.data.csrf_token
                }; ${loginResponse.headers["set-cookie"].join("; ")}`,
              },
              responseType: "json",
            }
          );
    
          if (statusReportResponse.status === 200) {
            // Save routes list to a file
            const fixturePath = `cypress/fixtures/routes-list.json`;
            const jsonData = JSON.stringify(statusReportResponse.data, null, "\t");
            fs.writeFileSync(fixturePath, jsonData);
            console.log("routes list saved successfully");
          } else {
            console.log("Failed to retrieve routes list");
          }
        } else {
          console.log("Login failed");
        }
      } catch (error) {
        console.error("An error occurred:", error.message);
      }
    }
    
    async function main() {
      try {
        // first login and save status report
        await loginAndSaveStatusReport();
      } catch (error) {
        console.error("An error occurred:", error.message);
      }
    }
    
    main();
    

    Used like

    node ./refresh-routes.js && ./npx cypress run

    So super complex I guess... not sure if you can use any of this. But it has caught the most regressions for us.

  • ๐Ÿ‡บ๐Ÿ‡ธUnited States aangel

    Thanks for this contribution. I've started to work on it in the issue fork. First step was check out Routes List (looks great) and to get the patch working; I also updated the documentation to include Routes List and how to patch it.

    I really like this approach because I wanted to test all the entries in a sitemap.xml fileโ€”but this seems better.

    Next I'll take a look at the test itself.

Production build 0.69.0 2024