Building ork 001

The backstory or "The part you skip because you do not care about it"

What's a good introduction without a backstory?
When I started working with PHP and WordPress about eight years ago, my first line of PHP was a PHPUnit test.

I only wrote code using TDD in my previous career, and really thought one could not write PHP code without testing it.
I look back at that time smiling: there are, indeed, people out there somewhere that do write code without writing tests for it.

As I spent time working on more WordPress projects and with more teams, I appreciated some factors that have not changed but softened my stance on testing.

First, writing automated tests for WordPress is not that widely documented, and the wider community is not that "into it". I've seen some talks at WordCamps about testing, but not that many. Mind: there are developers that do that, just not so many to tip the scale of the typical WordPress conversation; if you're doing it, you've got all my appreciation.

Second, there are tools and frameworks out there that will make testing WordPress easier; wp-browser objective is precisely that. Still, there are wide cracks in how they allow different types of testing to be covered, and if a developer's need falls in one of those cracks, then that developer will have to do a lot of research and problem solving to get anything done. When one masters the instrument, then the payoff will be immense, but that's a steep price to pay to start.

Third: most testing tools will work well on new projects and either impose a "refactoring tax" on the existing code to provide any meaningful return or outright not allow it.

With a touch of gatekeeping and elitism, all of the above pushes well-intentioned, but inexpert, people away from adopting tests far too often by conflating adoption and maintenance price into, simply, too high a price to pay to "just test code".

One test is better than none

When consulting with companies trying to adopt testing approaches, my first step is usually to demystify testing.

Sure, 100% coverage would be excellent; automated builds and flawless documentation of all the possible features and scenarios would be great to have, and blazing fast regression coverage would be even better.

But a developer, or a team of developers, has built something that does solve people problems; how can this be so bad?
Would we be having this conversation if we could run a command like magic-test-all-my-code without any further input, with complete test coverage and checks? Probably not, in most cases.
Ease of setup, adoption, and use, I think, is fundamental to frame this kind of conversations; if I could write magic testing frameworks, I would. I cannot, sadly.

So, in short, the ork project is my attempt at creating the testing solution I would have liked to find when I first got into PHP and WordPress development.

The first PHP and WordPress code I wrote was a messy blob in the theme functions.php file; it took me some time to be able to run tests for it. Like most, the first code they write will not be "new" code but will rely on differing levels of legacy code.

And I've run into a lot of that in the WordPress universe.

What is ork then?

This post is not by chance titled "Building ork": I have not built it yet.
Not all of it, at least.

How would ork compare to wp-browser and Codeception?
That much is clear: ork is not a power tool.
Its objective is not to cover all the possible testing requirements but to allow someone that "just wants to start testing" to do that with as little knowledge required as possible.

It's my take on the idea of getting more (testing) bang for the buck.

And then I'm building it from scratch. In Node. Cross-compiling binaries for different operating systems. And it will support PHP and JS syntax to write tests.

What could go wrong?

Getting down to business

All this boring introduction to get to the first line of code.

The first decision I took, as anticipated above, is to ship ork as a cross-compiled executable for Windows, Linux, and Mac.

This decision comes from my experience in maintaining wp-browser and from the pain caused by keeping compatibility with a sizable matrix including different PHP, WordPress, Codeception, and Composer versions. I love the project, but I would like to avoid this particular pain.

After some research, I've settled on using Node and pkg to write once and compile something that should work on all operating systems.
I forgot what using something that is just one file feels like in years of package management with PHP and JS.

After some project scaffolding, here is the src/bin.ts file that will act as the entry point of my application; a very humble "Hello World" type of message:

console.log('Hello ork!');

I've decided to use Typescript to develop the project to leverage its compile-time type-checking.
As it's been the norm for my development projects for some time now, I've set up a Docker image to create a portable development system.

This is the Dockerfile of the node image I will be using over and over to run and compile the code:

# Use LTS version of node.
ARG NODE_VERSION="14.16.1"
FROM node:${NODE_VERSION}
# Make npm cache, the node modules and /usr/bin/lib accessible by all.
RUN mkdir /.npm \
    && mkdir -p /usr/local/lib/node_modules \
    && chown -R 501:0 /.npm \
    && chmod -R 0777 /usr/local/
# Change the user to my user to avoid file mode issues.
ARG UID=0
ARG GID=0
USER ${UID}:${GID}
# Install the required build dependencies.
ARG NODE_PKG_CACHE_PATH
RUN npm install -g typescript pkg dts-gen prettier
# pkg will require caching to not take forever.
ENV PKG_CACHE_PATH="${NODE_PKG_CACHE_PATH}"
# No default entrypoint or command, overwrite the default ones.
ENTRYPOINT [""]
CMD [""]
# Expose debugging port.
EXPOSE 9229

To streamline the build process as much as I can, I've set up a Makefile to be used with the make binary, with a collection of solutions I've accumulated over time:

# Use bash as shell.
SHELL := /bin/bash
# If you see pwd_unknown showing up, this is why. Re-calibrate your system.
PWD ?= pwd_unknown
# PROJECT_NAME defaults to name of the current directory.
PROJECT_NAME = $(notdir $(PWD))
# Suppress `make` own output.
.SILENT:
# Make `build` the default target to make sure it will display when make is called without a target.
.DEFAULT_GOAL := build test
# Create a script to support command line arguments for targets.
# The specified targets will be callable like this `make target_w_args_1 -- foo bar 23`.
# In the target, use the `$(TARGET_ARGS)` var to get the arguments.
# To get the nth argument, use `export TARGET_ARG_2="$(word 2,$(TARGET_ARGS))";`.
SUPPORTED_COMMANDS := node_machine node npm
SUPPORTS_MAKE_ARGS := $(findstring $(firstword $(MAKECMDGOALS)), $(SUPPORTED_COMMANDS))
ifneq "$(SUPPORTS_MAKE_ARGS)" ""
  TARGET_ARGS := $(wordlist 2,$(words $(MAKECMDGOALS)),$(MAKECMDGOALS))
  $(eval $(TARGET_ARGS):;@:)
endif
# Set up some defaults.
NODE_VERSION = 14.16.1
# Any target commented with `## <description>` will show on `make help`.
help: ## Show this help message.
   @grep -E '^[a-zA-Z0-9\._-]+:.*?## .*$$' $(MAKEFILE_LIST) \
      | sort \
      | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}'
.PHONY: help

build: ts pkg ## Builds the ork application.

pkg: ## Packages the application.
   docker run --volume "$(PWD):$(PWD)" -w "$(PWD)" ork-app/node pkg package.json

_docker/node/uuid: _docker/node/Dockerfile
   [ -d "$(PWD)/.cache/pkg" ] || mkdir -p "$(PWD)/.cache/pkg"
   docker build \
      --build-arg NODE_VERSION="$(NODE_VERSION)" \
      --build-arg UID="$${UID}" \
      --build-arg GID="$${GID}" \
      --build-arg NODE_PKG_CACHE_PATH="$(PWD)/.cache/pkg" \
      --tag ork-app/node:latest \
      --iidfile _docker/node/uuid \
      _docker/node

## Build the node image, if not built already.
ts: _docker/node/uuid
   docker run --volume "$(PWD):$(PWD)" -w "$(PWD)" ork-app/node tsc
.PHONY: ts

node_machine: _docker/node/uuid ## Runs a generic command in the node container.
   docker run --volume "$(PWD):$(PWD)" -w "$(PWD)" ork-app/node $(TARGET_ARGS)

node: _docker/node/uuid ## Runs a node command, e.g. `make node -- <command>`.
   docker run --volume "$(PWD):$(PWD)" -w "$(PWD)" ork-app/node node $(TARGET_ARGS)

npm: _docker/node/uuid ## Runs a npm command, e.g. `make npm -- <command>`.
   docker run --volume "$(PWD):$(PWD)" -w "$(PWD)" ork-app/node npm "$(TARGET_ARGS)"

Whit these files in place, building the cross-system application is as easy as running make build.

» make build
> pkg@5.1.0

Following the configuration in the package.json file, the executables for Windows, Linux, and macOS have been created in the dist directory.
Running the command on macOS and Linux, I will get the following output:

» ./dist/ork-macos
Hello ork

The same will happen in the Windows terminal:

C:\Users\lucatume\Desktop>.\ork-win.exe
Hello ork

This might not look like much, but it lays the foundation for the flow I would like to adopt to develop the application.

Adding tests

There is almost nothing in the current CLI application that will warrant testing.
That is precisely why adding tests at this stage will be so cheap and convenient.

The tests I'm setting up for the CLI application, let's call it "acceptance" testing or "end-to-end" testing for the sake of analogy of what I would do while developing a Web application, are tests that will run the compiled ork binary in a sub-process and check its output and side effects.

For that, I've chosen to use the command-line-test package and the mocha testing framework.

To keep with the portability and reproducibility theme, I've set up two additional make targets to execute the tests in the node container with, and without, remote Node debugger support:

test_debug: _docker/node/uuid ## Runs the tests in debug mode, port
   docker run -i --volume "$(PWD):$(PWD)" -w "$(PWD)" -p "9229:9229" ork-app/node node --inspect-brk=0.0.0.0 \
      node_modules/mocha/bin/mocha \ --timeout 0 --ui bdd
.PHONY: test_debug

test: _docker/node/uuid ## Runs the tests.
   docker run -i --volume "$(PWD):$(PWD)" -w "$(PWD)" ork-app/node node \
      node_modules/mocha/bin/mocha --timeout 0 --ui bdd
.PHONY: test

I wrote a first test just to confirm the setup works correctly:

const {orkBin} = require('./_support.js');
const CliTest = require('command-line-test');
const assert = require('assert');

describe('hello ork', function () {
    it('should display hello ork', async function () {
        const cliTest = new CliTest();
        const {error, stdout, stderr} = await cliTest.spawn(orkBin);

        assert(error === null, error);
        assert(stdout !== null);
        assert(stdout === 'Hello ork\n', stdout);
    });
});

In case you're wondering, the _support.js file will only provide me with a portable solution to find the compiled ork binary depending on the system the tests are running on:

const os = require('os');
const osName = require('os-name')(os.platform(), os.release()).substr(0, 3);
const locateBin = function () {
    let binPostfix;
    switch (osName) {
        case 'mac':
            binPostfix = 'macos';
            break;
        case 'win':
            binPostfix = 'win.exe';
            break;
        default:
            binPostfix = 'linux';
            break;
    }

    return __dirname + `/../dist/ork-${binPostfix}`;
};

module.exports = {
    orkBin: locateBin()
};

I'm closing this first post on the passing test output:

» make test


  hello ork
    ✓ should display hello ork (408ms)


  1 passing (414ms)

Next

In my next post, I will start working on the actual code laying out the pieces of my application one by one as I develop the application concept.