Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

How to write a test for Yeoman that runs assertions when the generator code is done running? #1746

Open
csalmeida opened this issue Nov 27, 2020 · 0 comments

Comments

@csalmeida
Copy link

Whilst working with Yeoman to generate a project I had an issue when trying to write tests for it.

The generator makes one or more requests to download a few .zip files, extracts them and copies them to a directory. This can take a variable amount of time and when I write a test to assert file presence what ends up happening is that the assertions occur while the files are still being downloaded.

The code of my generator is:

"use strict";
// Required for generator to work
const Generator = require("yeoman-generator");
const chalk = require("chalk");
const yosay = require("yosay");

// Used to download and unzip files
// const https = require('https');
const https = require("follow-redirects/https");
const fs = require("fs");
const AdmZip = require("adm-zip");
const fse = require("fs-extra");

// Used to convey loading states in the terminal (loading, downloading...)
const Multispinner = require("multispinner");

module.exports = class extends Generator {
  prompting() {
    // Have Yeoman greet the user.
    this.log(yosay(`The ${chalk.blue("hozokit")} theme generator.`));

    const prompts = [
      {
        type: "input",
        name: "projectName",
        message: "What is your project name?",
        default: "hozokit" // Default to current folder name
      },
      {
        type: "confirm",
        name: "installWordpress",
        message: "Would you like Wordpress to be installed?",
        default: true
      }
    ];

    return this.prompt(prompts).then(props => {
      // To access props later use this.props.someAnswer;
      this.props = props;
      this.props.projectFolderName = this._dashify(this.props.projectName);
    });
  }

  writing() {
    // Installs Wordpress and Hozokit
    if (this.props.installWordpress) {
      this._installWordpress(this._dashify(this.props.projectFolderName));
    } else {
      // Installs Hozokit
      this._installHozokit(this._dashify(this.props.projectFolderName));
    }
  }

  /**
   * Downloads and installs the latest version of Wordpress in the project root directory.
   * This functionality should be optional and a Hozokit project should still be able to be generated whether or not this function runs.
   * @param {String} projectName Name of the project, used to name root folder. e.g 'hozokit' or this.props.projectName
   */
  _installWordpress(projectName) {
    // Creates the project directory if one is not already in place.
    this._createProjectDirectory(projectName);

    const spinners = ["Downloading Wordpress"];
    const m = new Multispinner(spinners);
    // Downloads a zipped copy of Wordpress into the folder.
    const zipPath = `./${projectName}/wordpress.zip`;
    const file = fs.createWriteStream(zipPath);
    const downloadURL = "https://wordpress.org/latest.zip";

    // This message is shown later to the user if any issues with the download come up.
    let downloadError = null;
    https
      .get(downloadURL, function(response) {
        response.pipe(file);

        // Use to add logic for when a request is in progress.
        // response.on('data', (data) => {
        // });

        response.on("end", () => {
          if (response.statusCode === 200) {
            m.success(spinners[0]);
          } else {
            downloadError = `Download has failed. (${response.statusCode})`;
            m.error(spinners[0]);
          }
        });
      })
      .on("error", error => {
        downloadError = error;
        m.error(spinners[0]);
      });

    // Displays a message once download is complete.
    m.on("success", () => {
      // This.log(`${chalk.green('Success:')} Download of Wordpress has completed.`);
      // this._extractWordpress(projectName)
      this._extractZip(
        projectName,
        "wordpress.zip",
        `./${projectName}/`,
        "Extracting Wordpress"
      );

      this._installHozokit(this._dashify(this.props.projectFolderName));
    }).on("err", error => {
      if (downloadError) {
        this.log(`${chalk.red("Error:")} ${downloadError}`);
      } else {
        this.log(
          `${chalk.red(
            "Error:"
          )} ${error} Download has been cancelled with an unknown error.`
        );
      }
    });
  }

  /**
   * Downloads and installs the latest version of Hozokit in the project root directory.
   * @param {String} projectName Name of the project, used to name root folder. e.g 'hozokit' or this.props.projectName
   */
  _installHozokit(projectName) {
    // Creates the project directory if one is not already in place.
    this._createProjectDirectory(projectName);

    // Generates progress spinners for user to see on the terminal.
    const spinners = [
      "Looking up latest Hozokit release",
      "Downloading Hozokit"
    ];
    const m = new Multispinner(spinners);

    // Downloads a zipped copy of Wordpress into the folder.
    const zipPath = `./${projectName}/hozokit-main.zip`;
    const file = fs.createWriteStream(zipPath);
    // Getting data on the URL is necessary to be passed into the request options.
    const releaseUrl = new URL(
      "https://api.github.com/repos/csalmeida/hozokit/releases/latest"
    );
    // Data on the latest hozokit release.
    let hozokit = null;

    // This message is shown later to the user if any issues with the download come up.
    let downloadError = null;

    // Retrieves information on the latest available release of Hozokit.
    // User-Agent is required for GitHub to take request.
    const options = {
      host: releaseUrl.host,
      path: releaseUrl.pathname,
      headers: {
        "User-Agent": "Hozokit Generator v0.0"
      }
    };

    https
      .get(options, function(response) {
        // Stores the response body for later use.
        let body = "";
        response.on("data", function(chunk) {
          body += chunk;
        });

        response.on("end", () => {
          if (response.statusCode === 200) {
            hozokit = JSON.parse(body);
            m.success(spinners[0]);

            if (hozokit) {
              const downloadUrl = new URL(hozokit.zipball_url);
              let options = {
                host: downloadUrl.host,
                path: downloadUrl.pathname,
                headers: {
                  "User-Agent": "Hozokit Generator v0.0"
                }
              };

              // Downloads the zip file of the latest release from Github.
              https
                .get(options, function(response) {
                  response.pipe(file);

                  response.on("end", () => {
                    if (response.statusCode === 200) {
                      m.success(spinners[1]);
                    } else {
                      downloadError = `Download has failed. (${response.statusCode})`;
                      m.error(spinners[1]);
                    }
                  });
                })
                .on("error", error => {
                  downloadError = error;
                  m.error(spinners[1]);
                });
            }
          } else {
            downloadError = `Request has failed. (${response.statusCode})`;
            m.error(spinners[0]);
          }
        });
      })
      .on("error", error => {
        downloadError = error;
        m.error(spinners[0]);
      });

    // Displays a message once download is complete.
    m.on("success", () => {
      this._extractZip(
        projectName,
        "hozokit-main.zip",
        `./${projectName}/`,
        `Extracting Hozokit ${hozokit.name}`
      );
    }).on("err", error => {
      if (downloadError) {
        this.log(`${chalk.red("Error:")} ${downloadError}`);
      } else {
        this.log(
          `${chalk.red(
            "Error:"
          )} ${error} Download has been cancelled with an unknown error.`
        );
      }
    });
  }

  /**
   * Extracts zip archives into a folder and moves them to a desired location.
   * Original zip and created folder are removed after uncompressing.
   * Used when installing Wordpress and Hozokit.
   * @param {String} projectName Name of the project, used to name root folder. e.g 'hozokit' or this.props.projectName
   * @param {String} fileZipName The name of the zip to be extracted. e.g 'hozokit-main.zip'
   * @param {String} copyPath (optional) The target path files should be copied to. This is a move since files are removed after extraction. Defaults to project directory. e.g './project-name'
   * @param {String} spinnerText (optional) The message shown whilst the spinner is in progress.
   */
  _extractZip(
    projectName,
    fileZipName,
    copyPath = null,
    spinnerText = "Extracting"
  ) {
    const spinners = [spinnerText];
    const m = new Multispinner(spinners);

    // Extracts contents of Wordpress.
    const extractPath = `./${projectName}/${fileZipName}`;
    const zip = new AdmZip(extractPath);
    // Makes use of the entries to figure out which folder name was created when file was extracted.
    const zipEntries = zip.getEntries();
    const extractedFolder = `./${projectName}/${zipEntries[0].entryName}`;
    zip.extractAllTo(`${projectName}/`, true);

    let extractError = null;

    // If a copy path is not provided files won't be moved.
    if (copyPath) {
      fse.copy(extractedFolder, copyPath, { overwrite: true }, err => {
        if (err) {
          extractError = `
          Could not copy files to ./${copyPath}. \n
          ./${err}
          `;
        } else {
          // Cleans up by removing extracted folder and zip.
          try {
            fs.rmdirSync(extractedFolder, { recursive: true });
          } catch (err) {
            extractError = `
              Could not remove extractedFolder. \n
              ./${err}
              `;
            m.error(spinners[0]);
          }

          // Remove zip file as it is not longer needed.
          try {
            fs.unlinkSync(extractPath);
          } catch (error) {
            extractError = `
            Could not remove ./${extractPath}. \n
            ./${error}
            `;
            m.error(spinners[0]);
          }
        }

        // If no error has been set, mark as successful.
        if (extractError === null) {
          m.success(spinners[0]);
        }
      });
    } else {
      // Cleans up by removing extracted folder and zip.
      // Lets user know that program did not work as intended.
      try {
        fs.rmdirSync(extractedFolder, { recursive: true });
      } catch (err) {
        extractError = `
          Could not remove extractedFolder. \n
          ./${err}
          `;
        m.error(spinners[0]);
      }

      // Remove zip file as it is not longer needed.
      try {
        fs.unlinkSync(extractPath);
      } catch (error) {
        extractError = `
        Could not remove ./${extractPath}. \n
        ./${error}
        `;
        m.error(spinners[0]);
      }

      this.log(`${chalk.red(
        "Error:"
      )} Could not copy files (copyPath is not present).
      Zip file and extracted files were removed.`);
    }

    // Displays error messages once extract is complete.
    m.on("err", error => {
      if (extractError) {
        this.log(`${chalk.red("Error:")} ${extractError}`);
      } else {
        this.log(
          `${chalk.red(
            "Error:"
          )} ${error} Extract has been stopped with an unknown error.`
        );
      }
    });
  }

  /**
   * It creates the project directory if one is not already in place.
   * It is useful when running generators separately that
   * require the project root folder to be in place before a task is performed.
   * @param {String} projectName Name of the project, used to name root folder. e.g 'hozokit' or this.props.projectName
   */
  _createProjectDirectory(projectName) {
    const directory = `./${projectName}`;
    try {
      if (!fs.existsSync(directory)) {
        fs.mkdirSync(directory);
        // This.log("Created temporary directory.");
      }
    } catch (err) {
      this.log(err);
    }
  }

  /**
   * It transforms a string separated by spaces into a dash separated one.
   * For example Hozokit Generator Project will be converted to hozokit-generator-project.
   * This is useful to create project directories for users without prompting them for the project folder name.
   * @param {String} value The value to be dashified. e.g 'Hozokit Generator Project'
   * @param {String} target (optional) The string that will be replaced with the separator. The default is a space ' '.
   * @param {String} separator (optional) The string the target value should be replaced with. The default is a dash '-'.
   */
  _dashify(value, target = " ", separator = "-") {
    const lowerCaseValue = value.toLowerCase();
    return lowerCaseValue.split(target).join(separator);
  }
};

This is the test I attempted to write (tried a few more things but all of them have similar results):

'use strict';
const path = require('path');
const assert = require('yeoman-assert');
const helpers = require('yeoman-test');

describe('generator-hozokit:app', () => {
  beforeAll(() => {
    return helpers
      .run(path.join(__dirname, '../generators/app'))
      .withPrompts({ projectName: 'Hozokit Test', installWordpress: false })
      .then(function() {
        // Checks that Hozokit was successfully extracted and present.
        assert.file(['../hozokit-test/wp-content/themes/hozokit-test', '../hozokit-test/wp-content/themes/hozokit-test/index.php', '../hozokit-test/wp-content/themes/hozokit-test/templates/base.twig']);
    
        // Checks that Wordpres has not been installed.
        assert.noFile(['wp-login.php', 'index.php', 'wp-includes']);
    
        // Checks that no zip files remain on the system.
        assert.noFile(['*.zip',]);
      });
  });
});

Writing tests is something I'm still learning about but the issue I'm having I just can't seem to make sense of a solution or find more relevant information online or on the docs, I wonder if someone could help me since I'm really curious to know what can be done to solve it and would be a great lesson for me.

Any help or tips on how to get around this would be very appreciated, thank you. 🙏

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

1 participant