Showing posts with label Lucee/CFWheels/Docker series. Show all posts
Showing posts with label Lucee/CFWheels/Docker series. Show all posts

Monday, 3 May 2021

Testing: A Horror Story

G'day:

My adventures in Lucee / CFWheels / Docker continues. This time I'm looking at what CFWheels offers by way of testing.

2021-05-04 - editorial update

In this article I am very - but I feel justifiably - harsh about RocketUnit (you need to read down a bit before you get to this). I'm also harsh towards the CFWHeels team's historical decision to include it in CFWheels. I really do think that was a poor decision. But so be it, it's done: other than mentioning it, there's no point me dwelling on it beyond that.

Some of the tone and wording of this article has be read as an indictment of CFWheel and/or its team. Beyond that initial poor decision, all my opprobrium here is directed at RocketUnit, not CFWheels, and not the team. I can see how people would think otherwise though.

To be clear: this article is largely about RocketUnit. CFWheels was just the path via which I came to know about it.

I'm going to be brief, because it's so appalling I don't want to waste too much time on it. It can be summarised thus:

Are you f***ing joking?

Firstly I'm gonna quote the CFWheels docs (Testing Your Application › The Test Framework):

Like everything else in CFWheels, the testing framework is very simple, yet powerful. You don't need to remember a hundred different functions because CFWheels' testing framework contains only a handful.

(my emphasis there)

Sorry but I see wording like this implying something about CFML developers' capabilities all the time, and it really annoys me. To me that statement is patronising as well as disingenuous. It plays on this perception that somehow CFML devs aren't capable of doing anything unless it's reeeeally easy, and that that is just how things should be. Can we please not? Can we please not normalise CFML devs as somehow not being able to cope with a small amount of complexity if it will ultimately assist them growing as developers? It's also a bit disingenuous because no testing framework requires one to learn any more than a handful of methods, and learning how to write code is our job. It suggests testing tooling is somehow usually hard (which it just isn't).

Next I read further into the docs:

Evaluation

assert(): This is the main method that you will be using when developing tests. To use, all you have to do is provide a quoted expression. The power of this is that ANY 'truthy' expression can be used.

An example test that checks that two values equal each other:

function testActualEqualsExpected() {
    actual = true;
    expected = true;
    assert("actual eq expected");
}

I'm sorry, come again? You. Give. It. A. Quoted. Expression. It's using evaluate(), isn't it? Now I'm not actually fundamentally against evaluate as a thing. Not like some of the CFML community groupthink. It has its place, just that that place is seldom "in your code": one hardly ever needs to use it. I can't wait to see why it's being used here. Other than, like, cos for some reason the assert function expects a string, so it needs to be evaluated to even work.

And "[the] power of this is that ANY 'truthy' expression can be used"? So: just like any other implementation of assert that any other testing framework ever written. Except none of those need to pass a string to the assert function. This is not a MSP of this testing framework, and the implementation we're being shown is inferior to any other testing framework I've seen. I won't look at the actual code for this just yet, as there's still more horror to see in the guidance docs first.

I'm going to wind back up the docs a bit now:

Do not var-scope [any] variables used in your tests. In order for the testing framework to access the variables within the tests that you're writing, all variables need to be within the component's variables scope.

When I first read that I was like "oh yer having a laugh, right?" and it was not until I got to the "boolean expression as a string" and "evaluate" that it suddenly made "sense". Because the string expression needs to be evaluated within the implementation of assert, of course the variables in the "expression" can't be function-local to your test: assert's code won't be able to see those. So now because the implementation has chosen to pass the test expression as a string, it's forcing us to write bad, flaky, fragile test code, with variables bleeding all over the place by design. I can't really see how this framework could ever be used seriously outside a proof of concept or other very superficial environment. It'd drive the devs batty.

And now I hasten to add, the rest of the CFWHeels docs on testing are actually pretty helpful when it focuses-away from the test framework, and back to CFWheels stuff.

So what's with this test code? What is going on with this assert function? Oh yeah, btw: assert is the only assertion this framework offers. So there's goes your elegant, expressive, easy to understand test code. It's just a bunch of vanilla assert calls now. This is a telling stark contrast every other test framework out there that has decided to implement a lot of different assertions, and sees this as a good thing. I mean obviously at the end of the day they are all asserting if something is true; if you look at the internal implementations, generally all the facade assertions end up doing just that: calling a central "assert-true" assert method. The situation-specific assertions are there to make your test code easier and simpler to understand, which is vital in testing. I cannot understand why there was a perception that it was a good thing for the framework to have only one assertion.

Right. The code. Which is all in wheels/test/functions.cfm (nice file name there, CFWheels Team :-|. And I also have yet to work out why CFWheels is designed such that all the code for its .cfc files are implemented in .cfm files: this is a question for another day).

This is interesting:

Copyright 2007 RocketBoots Pty Limited - http://www.rocketboots.com.au

OK so here's where I clock that CFWheels have just bundled this "RocketUnit" thing into the framework. OK so in defence of the CFWheels team, this is possibly just a really poor decision to include this, rather than the team actively writing this… this… thing. I note that CFWheels (in 2021) still bundles only v1.0 of RocketUnit (from something like 2007).

And here we go:

public void function assert(required string expression) {
    // other stuff snipped
    if (!Evaluate(arguments.expression)) {

And why is it doing this? This is hilarious. The way this code has been implemented, and the reason that one needs to pass a string to assert is because if the assertion fails, then the code basically picks through the string, does a primitive tokenisation effort to find dynamic expressions, and then evaluates them again to get values to return in the assertion-failed message. EG; if your expression is "x == 3" and variables.x is 4, it'll re-evaluate each element of the string so it can say something like "uh-oh x was 4 not 3". And the entire reason it needs to do this with an equality-check is it's shot itself in the foot by only having assert, instead of taking the obvious route of having an equality assertion that takes two values to compare. Neither of which need to be in a global scope; neither of which need to be re-evaluated strings, because the assertion was passed their values. It could be called, I dunno, assertEquals or something.

It actually gets better (read: "worse"). In v2.x of RocketUnit, there's no need for the quoted string any more, because what the assertion implementation does when the assertion fails is it thows a fake exception to generate a callstack, and then crawls its way up that to try to find the line of code that called assert, and extract the expression from that. Seriously, have a look:

try {
    throw(errorCode=DUMMY_ERRCODE);
} catch(any) {
    // assert is one stack frame up from this function [1], therefore [2]
    source = cfcatch.tagContext[2].codePrintPlain;
    startLineNumber = lineNumber = cfcatch.tagContext[2].line;
}

If you look further down, you can see how the implementation itself knows how flaky its own approach even is, given the various places the code that tries to extract the expression needs to bail out.

Argh!

When discussing this with another CFML community member, they sent me this:

* see footnote regarding usage of this image

I think this is something that should have set-off alarm bells when the RocketUnit project first started… evolving.


Here's how it would seem that one needs to safely write tests with this shambles:

component extends="app.tests.Test" {

    function testBasicRoutingWorks() {
        variables.response = processRequest(
            params = {
                controller = "testroute",
                action = "debug"
            },
            returnAs = "struct"
        )
        try {
            assert("variables.response.status eq 200")
            assert("variables.response.body contains 'EXPECTED_CONTENT'")
        } finally {
            structDelete(variables, "response")
        }
    }
}

One can't just leave variables-scoped variables lying around the place in test code, so you need to get rid. Test code needs to be well encapsulated and robust and not prone to interference from unexpected avenues. Or one could just have one test per CFC. Or one could painstakingly make sure that tests don't accidentally share variables. Or hey just suck it and see (I suspect this is what RocketUnit expects). It's just too easy to accidentally not reinitialise a variable and be using an uncontrolled value from another test in a subsequent one.

Do not var-scope [any] variables

I have to wonder why - at the point one realises one needed to document something that contravenes standard good coding practice that languages go out of their way to accommodate - the person didn't go "ah now lads we've f***ed this one", and rethink things.


Do me a favour if you're using CFWheels? Don't use its in-built testing framework. It's shite. Use TestBox instead. It's dead easy to use, still supported, still being actively developed on a daily basis, and makes for really nice easy to read, easy to develop test code. There's a strong and willing community out there to help you if you get stuck with anything. It'll also be a cinch to test yer CFWheels work with, as it's completely situation-agnostic.

And to the CFWheels team: please consider just ripping this out of CFWheels wholesale. This is really letting your framework down, I think.

Righto.

--
Adam

* I am not the copyright holder of this image. I'm hoping I'm covered by "fair use" here, but if you are the copyright holder and disagree: let me know, and I'll replace it with something else.

Installing and running CFWheels in my Lucee Docker container

G'day:

2021-05-05

Some of the way I've needed to configure things to get CFWheels working across the board have been tweaked/augmented from what I have in this article. A stripped down version of the steps I currently have is at "Short version: getting CFWheels working outside the context of a web-browsable directory". I can't be arsed rewriting this every time I find something new I need to tweak, but I'll keep that other article up to date.

If you've been following along in the previous articles in this series (Lucee / CFWheels / Docker), You'll know I have a set of Docker containers running Nginx, MariaDB and Lucee. I have been configuring this lot using a TDD approach, so I have a suite of tests that demonstrate everything is integrated nicely. Now it's time to install CFWheels as well, and have a look at it. I will be continuing my TDD approach to doing this work, so will list the cases I'm addressing at the heading of each step. Update, two weeks later: no I won't. This took so bloody long and was such a pain in the arse, I'm not gonna bother with that.

Overall aim: "It serves the CFWheels welcome page"

Reading ahead through the CFWheels › Installation › Test it docs, I see I should expect a welcome page when I browse to the site, all going well. So I will write a test to expect this (CFWheelsTest.cfc):

component extends=testbox.system.BaseSpec {

    function run() {
        describe("Tests CFWheels app installation", () => {
            it("serves the CFWheels welcome page after installation", () => {
                http url="http://cfml-in-docker.frontend/" result="response";

                expect(response.status_code).toBe( 200, "HTTP status code incorrect")
                expect(response.fileContent.trim()).toBe( "NOT SURE YET", "Response body incorrect")
            })
        })
    }
}

I dunno what form this welcome will take as yet, so I've just got some text that definitely won't be right, and will start with that. I'd improve this later when I know what to check for.

Attempting (spoilers: and failing) to install using CFWheels CLI

Looking at the CFWheels installation docs, I should use CommandBox to do the installation from ForgeBox, so I'll give that a go. I've already got CommandBox installed to run my tests. One thing I've noticed from reading ahead, the default location the files for CFWheels get put in a manual install is just slapped in the web root. That's… erm… "less good than it could be", and I won't be doing this. I've already gone through this with TestBox, so you can read why in that earlier article: "Adding TestBox, some tests and CFConfig into my Lucee container › It needs to only expose public stuff to the public"

I can tell CommandBox where to install things easily enough:

root@efe37f109b87:/var/www# box install id=cfwheels-cli directory=vendor
√ | Installing package [forgebox:cfwheels-cli]
root@efe37f109b87:/var/www#

And this has updated my box.json for me:

root@efe37f109b87:/var/www# cat box.json
{
    "dependencies":{
        "cfwheels-cli":"^0.4.0"
    },
    "devDependencies":{
        "testbox":"^4.2.1+400"
    },
    "installPaths":{
        "cfwheels-cli":"vendor/cfwheels-cli/",
        "testbox":"vendor/testbox/"
    },
    "testbox":{
        "runner":"http://localhost:8888/test/runTests.cfm"
    }
}
root@efe37f109b87:/var/www#

So far… so good.

The way the docs are recommending I set up my new CFWheels app is through this CLI thing I just installed, by going box wheels new. It sounds to me like this is going to assume it can do whatever it likes in its install directory, so to start with I'm just going to do the install in a temp directory and have a look at what it does, and whether it will collide with my existing work. It's nae bother if it does, I'll move my stuff around, but I just want to know what I'm getting myself into first.

root@efe37f109b87:/var/tmp/cfwheels# pwd
/var/tmp/cfwheels
root@efe37f109b87:/var/tmp/cfwheels# box wheels new
ERROR (5.2.1+00295)

Command "wheels new" cannot be resolved.

Please type "help" for assistance.

root@efe37f109b87:/var/tmp/cfwheels#

OK. Not off to a good start. I gulped and wondered whether this needs to be run from within the directory I did the install, so I switched over to /var/www/vendor/cfwheels-cli and tried there: same result. I also tried doing it from within CommandBox's special wee shell, but the results were the same.

I tried to see what help was on offer for CFWheels in general:

CommandBox> wheels help

**************************************************
* CommandBox Help for wheels
**************************************************


Command [wheels] not found.

Fine. OK, look: I don't know what's going on here; and to be completely honest, I don't care. The manual installation process is "stick the files in a directory", and I can pretty much manage that by myself (cough: don't speak too soon, Cameron). I don't think I need the Wheels CLI to do this for me. Or, as is the case here: not do it for me. De-installing that.

Installing just CFWheels

I'll still use ForgeBox to install the app though:

root@efe37f109b87:/var/www# box install id=cfwheels directory=vendor/cfwheels
√ | Installing package [forgebox:cfwheels]
root@efe37f109b87:/var/www#

One thing to note here is that I needed to specify the installation subdirectory for CFWheels too, ie: directory=vendor/cfwheels. When I installed Testbox, I only had to specify the base directory, ie: directory=vendor, and the testbox subdirectory was created for me within that. Now I don't mind whether I need to not specify the directory the installation actually goes in, or whether I don't need to specify it. But it should be one or the other. I'm not sure what's going on here.

Anyway, having done this, I see all the files in a subdirectory how one might want. Kinda:

root@efe37f109b87:/var/www# ll vendor/cfwheels/
total 88
drwxr-xr-x 16 root root 4096 Apr 25 18:35 ./
drwxr-xr-x  1 root root 4096 Apr 25 18:35 ../
-rwxr--r--  1 root root   67 Nov 22 09:57 Application.cfc*
-rwxr--r--  1 root root  975 Apr 25 18:10 box.json*
drwxr-xr-x  6 root root 4096 Apr 25 18:35 config/
drwxr-xr-x  2 root root 4096 Apr 25 18:35 controllers/
drwxr-xr-x  2 root root 4096 Apr 25 18:35 events/
drwxr-xr-x  2 root root 4096 Apr 25 18:35 files/
drwxr-xr-x  2 root root 4096 Apr 25 18:35 global/
drwxr-xr-x  2 root root 4096 Apr 25 18:35 images/
-rwxr--r--  1 root root   52 Nov 22 09:57 index.cfm*
drwxr-xr-x  2 root root 4096 Apr 25 18:35 javascripts/
drwxr-xr-x  2 root root 4096 Apr 25 18:35 miscellaneous/
drwxr-xr-x  2 root root 4096 Apr 25 18:35 models/
drwxr-xr-x  2 root root 4096 Apr 25 18:35 plugins/
-rwxr--r--  1 root root   52 Nov 22 09:57 rewrite.cfm*
-rwxr--r--  1 root root  162 Nov 22 09:29 root.cfm*
drwxr-xr-x  2 root root 4096 Apr 25 18:35 stylesheets/
drwxr-xr-x  4 root root 4096 Apr 25 18:35 tests/
drwxr-xr-x  2 root root 4096 Apr 25 18:35 views/
drwxr-xr-x 14 root root 4096 Apr 25 18:35 wheels/
root@efe37f109b87:/var/www#

Most of that stuff is stub files for my Wheels application; except for the wheels directory which is the actual CFWheels application. I expected this, because the default approach to installing this thing is - remember - "slap it all in the web root". I'll deal with this next, but first I see a problem with box.json:

root@efe37f109b87:/var/www# cat box.json
{
    "dependencies":{
        "cfwheels":"^2.2.0"
    },
    "devDependencies":{
        "testbox":"^4.2.1+400"
    },
    "installPaths":{
        "testbox":"vendor/testbox/"
    },
    "testbox":{
        "runner":"http://localhost:8888/test/runTests.cfm"
    }
}
root@efe37f109b87:/var/www#

It's not put the installPath in for CFWheels. This is no good when I come to use this for a Dockerised deployment. I will quickly manually edit this file and do a rebuild and see what happens. Back in a few min.

(Oh I also note that CommandBox has written "vendor/testbox/" in there, despite me saying just "vendor". I mean… fine…: but why did it not do the same for CFWheels?)

Cool so I did the rebuild, and having put the installation path for CFWheels into box.json manually, it all seemed to work:

root@78cb5ad3bf2c:/var/www# ll vendor
total 16
drwxr-xr-x  1 root root 4096 Apr 25 18:47 ./
drwxr-xr-x  1 root root 4096 Apr 25 18:47 ../
drwxr-xr-x 16 root root 4096 Apr 25 18:47 cfwheels/
drwxr-xr-x  9 root root 4096 Apr 25 18:47 testbox/
root@78cb5ad3bf2c:/var/www# ll vendor/cfwheels/
total 84
drwxr-xr-x 16 root root 4096 Apr 25 18:47 ./
drwxr-xr-x  1 root root 4096 Apr 25 18:47 ../
-rwxr--r--  1 root root   67 Nov 22 09:57 Application.cfc*
-rwxr--r--  1 root root  975 Apr 25 18:47 box.json*
drwxr-xr-x  6 root root 4096 Apr 25 18:47 config/
drwxr-xr-x  2 root root 4096 Apr 25 18:47 controllers/
drwxr-xr-x  2 root root 4096 Apr 25 18:47 events/
drwxr-xr-x  2 root root 4096 Apr 25 18:47 files/
drwxr-xr-x  2 root root 4096 Apr 25 18:47 global/
drwxr-xr-x  2 root root 4096 Apr 25 18:47 images/
-rwxr--r--  1 root root   52 Nov 22 09:57 index.cfm*
drwxr-xr-x  2 root root 4096 Apr 25 18:47 javascripts/
drwxr-xr-x  2 root root 4096 Apr 25 18:47 miscellaneous/
drwxr-xr-x  2 root root 4096 Apr 25 18:47 models/
drwxr-xr-x  2 root root 4096 Apr 25 18:47 plugins/
-rwxr--r--  1 root root   52 Nov 22 09:57 rewrite.cfm*
-rwxr--r--  1 root root  162 Nov 22 09:29 root.cfm*
drwxr-xr-x  2 root root 4096 Apr 25 18:47 stylesheets/
drwxr-xr-x  4 root root 4096 Apr 25 18:47 tests/
drwxr-xr-x  2 root root 4096 Apr 25 18:47 views/
drwxr-xr-x 14 root root 4096 Apr 25 18:47 wheels/
root@78cb5ad3bf2c:/var/www#

Now my challenges start.

Reorganising the file structure

I have to take that listing above, and reorganise it as follows:

adam@DESKTOP-QV1A45U:/mnt/c/src/cfml-in-docker$ tree . --dirsfirst
.
├── public
│   ├── images
│   ├── javascripts
│   ├── stylesheets
│   ├── Application.cfc
│   ├── index.cfm
│   └── rewrite.cfm
├── src
│   ├── config
│   ├── controllers
│   ├── events
│   ├── files
│   ├── global
│   ├── miscellaneous
│   ├── models
│   ├── plugins
│   ├── views
│   ├── Application.cfc
│   └── root.cfm
├── tests
│   ├── functions
│   │   └── Example.cfc
│   ├── requests
│   │   └── Example.cfc
│   └── Test.cfc
└── vendor
   └── cfwheels
       └── wheels


Getting CFWheels to work with that file structure

A couple of week has passed since I wrote the preceding paragraph. In the interim I've written a couple of articles about some side challenges I have had as I progressed this work. They weren't directly-related enough to what I'm talking about here to include them, hence I've published them separately:

Along the way I've enhanced the testing I've had to do to ensure my tinkerings worked and didn't break anything. As it currently stands I have a CFWheels-driven site "working": I have created some test endpoints that behave as I expect, and I can run its internal test suite. That's a pretty light definition of "working", I know.

I'll continue to detail the CFWheels config changes I needed to make to get everything working…

After doing the work for that "How TDD and automated testing helped me…" article above, I had a decent battery of tests that acted as a safety net for expectations I had for my site URLs:

I'm showing you the status as they are now, after I've done the work; obviously as the work was under way they weren't all a) there; b) passing. I just can't be bothered rolling my code back to show them that way, and in a way this is a more useful listing anyhow.

Oh and ignore the skipped ones. There's a bug in CFWheels that prevents those from working properly: the 404 page in dev more returns a 200-OK. I've left the tests skipped as a reminder I need to revisit them once the bug is fixed. I actually note it's been fixed (nice work!), but the version it's in ain't up on ForgeBox yet.

So… how did I get the thing to work?

Well. Firstly. How did I not get it to work. My expectations were that any well-designed third-party app would not make assumptions about where it was in the file system; or where any files implementing it happened to be. All I expected to have to do here is to set a mapping for CFWheels, and a mapping for my app / implementation code, tell my code where the CFWheels application is and then it'd just work. However I'm afraid CFWheels is not a very well-designed application, so that was a non-starter.

Core CFWheels code has been implemented so that it will only work if the wheels directory is right slap-bang in the middle of my implementation code. Primarily this line of code in wheels/events/onapplicationstart.cfm:

$include(template = "config/settings.cfm");

And that weirdo (name and implementation) $include function does this to it:

<cfinclude template="../../#LCase(arguments.template)#">

Neat. Let's dispense with our carefully-cased file names, cos we'll enforce lower-case on them all. That's definitely the job of a function called "include". Sorry "$include". And we'll just force the all to be two driectories up from wheels/global because isn't that where everybody puts their files?

I'm going to need to put that wheels directory into the middle of my source-controlled application code files, are't I? Sigh. I'm not checking it in to source control though: that would be too far beyond the pale. I'm installing it to where it belongs: vendor, and then getting Docker to copy it into my source-controlled directory once it's there:

RUN git clone git@github.com:adamcameron/cfml-in-docker.git .

# irrelevant stuff snipped

RUN mkdir -p vendor
RUN box install

RUN cp -R /var/www/vendor/cfwheels/wheels /var/www/src/wheels

I'm also .gitignore-ing /src/wheels/ so it doesn't accidentally end up in source control.

I know this is a really jerry-built, but… well: my hands are kind of tied.

The next step can be sorted out via config settings CFWheels exposes via config/settings.cfm:

srcPrefix = "../src"

set(eventPath = "#srcPrefix#/events")
set(filePath = "#srcPrefix#/files")
set(modelPath = "#srcPrefix#/models")
set(modelComponentPath = "#srcPrefix#/models")
set(pluginPath = "#srcPrefix#/plugins")
set(pluginComponentPath = "#srcPrefix#/plugins")
set(viewPath = "#srcPrefix#/views")
set(controllerPath = "#srcPrefix#/controllers")

set(webPath = "")
set(imagePath = "images")
set(javascriptPath = "javascripts")
set(stylesheetPath = "stylesheets")

set(wheelsComponentPath = "cfmlInDocker.wheels")

This is almost OK. Except for the headless-function use of set, which appears in here as if by magic; and for some unfathomable reason that it's implemented in a .CFM file not in a method of maybe a Settings.cfc file or something a bit more OOP. The fact that this .CFM file is ultimately included into a .CFC file makes the situation worse, not better, IMO. This is endemic in the application design of CFWheels btw.

Getting the testing framework to work

The other thing I needed to do is to add some mappings so the inbuilt CFWheels testing "framework" can actually run tests. This is all web-driven, btw. These had to go into src/Application.cfc

thisDirectory = getDirectoryFromPath(getCurrentTemplatePath())
this.mappings["/public/wheels"] =  getCanonicalPath("#thisDirectory#wheels")
this.mappings["/public/tests"] = getCanonicalPath("#thisDirectory#../tests")
this.mappings["/app/tests"] = getCanonicalPath("#thisDirectory#../tests")

You might as why I need to put these into my application's Application.cfc, and I couldn't put them in a tests/Application.cfc that happened to extend the app's one. Yes, this was my expectation as well: this crazy notion of OOP and separation of concerns. However CFWheels apparently runs its tests via a controller in your web app, then there isn't really a tests/Application.cfc that I can leverage.

After that the UI in the test framework was still breaking, creating links incorrectly. This ended up being because I needed to tell CFWheels that I am rewriting URLs. Not sure why, but there you go (Tom King set me straight on this, on the CFWheels subchannel of the CFML Slack channel: he is a star and has been a big help to me in all of this). The settings are also in config/settings.cfm:

set(URLRewriting="On")
set(rewriteFile="index.cfm")

And after doing that lot: all my TestBox tests were green! And there's even a test in there that tests that CFWheels' tests are working, which they are. I say "all of them", that just means the coupla stub tests they include as examples, and two wee tests I stuck in as proof-of-working. I'm not doing anything major in there. But it's green:

I'll not go into the implementation of those tests here. I'll look at that particular horror story in a separate article.

And the site

Tests are one thing, but I better demonstrate to you that the CFWheels-driven site is up and running too:



Conclusion

Detailing how to get this working took a lot less time to write (and for you to read) than it did for me to work out. It was not helped by my various Nginx-config-shortcomings; but I did have to dive into CFWheels's code more than I would have liked, and that was an exercise in frustration in itself as the way it's been implemented is… "obstructive". As I mentioned above Tom King patiently answered a bunch of questions for me, which was also a big help.

However here I am. I've got my app working. It would not surprise me if there were some other config tweaks I need to make down the track, but I can start doing some exercises of adding some of me own routes, controller, models, views in here now. I've worked through the CFWHeels tutorials and a bunch of their screencasts, and it seems easy enough to throw simple things together. I have my suspicions that as soon as requirements are no longer simplistic then the way CFWheels is designed / implemented might start getting in its own way, but I'll give it the benefit of the doubt for now.

Righto.

--
Adam

 

 

 

 

But wait! There's more!

Just after pressing "Publish" on this, I foolishly clicked about on the UI of the site home page. And I clicked on "Docs":

And the thanks I got for doing that was this:

I looked through the CFWheels code to see what it was ass-u-me`ing, and it's here in wheels/functions.cfm:

this.wheels.rootPath = GetDirectoryFromPath(GetBaseTemplatePath());

// ...

// Add mapping to the root of the site (e.g. C:\inetpub\wwwroot\, C:\inetpub\wwwroot\appfolder\).
// This is useful when extending controllers and models in parent folders (e.g. extends="app.controllers.Controller").
this.mappings["/app"] = this.wheels.rootPath;

And in this case, GetBaseTemplatePath() is the index.cfm file in the public, not the app root. So that won't do. I thought this might be a show stopper because I can't override that because of the way CFWheels is architected. However all was not lost because as far as I can tell that base /app mapping is never used by itself, it's only ever used as the base for app.controllers, app.models, and app.wheels. I can just make the correct mappings to those, in my Application.cfc. But first a quick test:

it("serves the Docs page", () => {
    http url="http://cfml-in-docker.frontend/wheels/docs" result="response";

    expect(response.status_code).toBe(200, "HTTP status code incorrect")
    expect(response.fileContent).toInclude("<title>Docs | CFWheels</title>")
})

Now we can fix it:

thisDirectory = getDirectoryFromPath(getCurrentTemplatePath())
this.mappings["/public/wheels"] =  getCanonicalPath("#thisDirectory#wheels")
this.mappings["/public/tests"] = getCanonicalPath("#thisDirectory#../tests")
this.mappings["/app/tests"] = getCanonicalPath("#thisDirectory#../tests")

this.mappings["/app/controllers"] = getCanonicalPath("#thisDirectory#controllers")
this.mappings["/app/models"] = getCanonicalPath("#thisDirectory#models")
this.mappings["/app/wheels"] = getCanonicalPath("#thisDirectory#wheels")

And that sorted it. I wonder what the next glitch is gonna be. But I'm gonna stop looking. For now…

RightoAgain.

--
AdamAgain

Sunday, 2 May 2021

How TDD and automated testing helped me solve an Nginx config problem I had created for myself

G'day:

I have a "website" I'm building on as part of a series of articles I'm writing about Lucee / CFWheels / Docker. I have a Docker container running Nginx which proxies requests for CFML code to a Docker container running Lucee.

Due to the nature of web applications, I have my web-accessible assets - JS, CSS, image and "entry point" index.cfm and Application.cfc files - in a public directory off my app root; and adjacent to that I have a src directory for my code, and vendor directory for third-party code (like stuff I install from ForgeBox via CommandBox).

This /public abstraction needs to be hidden from the end user: they're going to want to be browsing to - for example - http://example.com/, not http://example.com/public. So this means the proxy from Nginx to Lucee also needs to deal with that. It is imperative that no CFML files are publicly exposed other than the aforementioned index.cfm and Application.cfc

I'm terrible at configuring Nginx, and mak a lot of mistakes. Some mistakes are obvious because Nginx flat-out refuses to start. Others are less obvious, and bleed out as "unexpected behaviour" later in the piece. Knowing this, I approached the exercise in a TDD fashion; identifying what cases need addressing and writing tests for them, and then doing the config work to make each pass. Note: my wording is ambiguous there: I did not write more than one test at a time. I wrote a test, then got the config to make that test pass. Then I wrote the next test and reconfigured to make that pass (whilst also keeping the earlier ones passing too). I've already detailed some of this in my earleir article "Adding TestBox, some tests and CFConfig into my Lucee container".

As a result of these efforts to get Nginx working how I expected it to, I ended up with these green tests:

(Ignore how I'm skipping some of these tests. This is down to a bug in CFWheels that doesn't report 404 situations with an actual 404 status code when in dev mode. This does demonstrate though how I identified a shortcoming in behaviour whilst TDDing the work, that said).

And that's great. At every step as I was configuring more and more of Nginx's handling of requests, I could see that any change I made had a) addressed the new requirement; b) not broken any previous requirement. And it was indeed the reality at times that something I tried fixed one issue, but broke something else.

Back to those tests I'm skipping. This showed to me that I was short some cases: CFWheels handles things differently from how I expect it to in some situations, so I figured I had better have two flavours of test: one set for proxying to non-CFWheel-handled URLs; another set for when the URLs are within CFWheels domain. And I'm glad I did make this call, because it showed-up a bug in my Nginx config:

And when I checked those URLs, I saw the problem:

expectedParamValue = "expectedValue"
// ... rest of test not relevant here
expect(response.fileContent).toInclude(
    "Expected query param value: [#expectedParamValue#]",
    "Query parameter value was incorrect (URL: #testUrl#)"
)

But what I was seeing at that URL was this:

Expected query param value: [expectedValue?testParam=expectedValue]

The query string part (including the ?) was being appended to the URL sent to Lucee twice (same problem for both those failing tests). I was pretty puzzled how my previous non-Wheels test was passing, and it still seemed legit. Bemusing. However I was actively appending the query string in my proxy_pass URL, so that was "clearly wrong":

proxy_pass  http://cfml-in-docker.lucee:8888/public$fastcgi_script_name$is_args$args;

I got rid of that, and figured "I had better go and check why those other tests are passing after I re-run the tests here, to check that change:


Dammit. Now the CFWheels-specific tests are passing, but the non-Wheels ones are failing. Time for me to RTFM, cos I'm clearly doing something wrong here.

The first thing I'm going wrong is here:

proxy_pass  http://cfml-in-docker.lucee:8888/public$fastcgi_script_name;

$fastcgi_script_name is a PHP thing, and whilst it coincidentally holds the URI I want, it's the wrong thing to have here. So I put $request_uri back in there.

Right and that broke all the path_info CFWheels tests, so wasn't right. I decided to read more closely. It turns out that request_uri is the whole original URI (including the path_info and query string), and thus is ignoring my rewrites, and it was the reason that the query params were getting doubled up. In my rewrite I had this:

location @rewrite {
    rewrite ^/(.*)? /index.cfm$request_uri last;
    rewrite ^ /index.cfm last;
    return 404;
}

I just wanted $uri, which is just the document URI part of the requested URI, and it also reflects any changes made to that URI during rewrites and what-have-you. So once I used that in my rewrite and for my proxy_pass URL, the tests now look better:

I've abbreviated how long it took me to work this out, and how many cycles of trial end error it too me. Having those automated tests in place were gold because after each iteration I knew how wrong I was - for all cases - in half a second. I didn't need to manually go "OK, did it work for this?" "did it break this other one?" etc, for 20-odd tests.

It was also a big help to me to take the TDD path here, and stop and think & reason about exactly what my expectations ought to be for each of the cases I had. It also lead me to add more cases, such as the combinations of "it has both path_info and query parameters", as well as realising the path through the Nginx config was different for URLs aimed at Wheels (which are completely rewritten), and the ones directly to files in the public directory. I could easily cover both cases by duplicating the tests and changing the URLs sligthly.

Things seem to be working now, but if I find something else wrong, I will first work out what my expectations of it to be right are, and write a quick test for it. Then I'll fix it (without breaking anything else).

For now though: I'm fed-up with Nginx & CFML & CFWheel and I'm gonna do something els for a while. But I'll be back to it later this afternoon: I'm wll behind wehre I want to be with this stuff, and using the bank-holiday weekend to catch up a bit.

The "final" state of my Nginx site config is (docker/nginx/sites/default.conf):

server {
    listen 80;
    listen [::]:80;

    #rewrite_log on;

    server_name cfml-in-docker.frontend;
    root /usr/share/nginx/html;
    index index.html index.cfm;

    resolver 127.0.0.11;

    location / {
        try_files $uri $uri/ @rewrite;
    }

    location @rewrite {
        rewrite ^/(.*)? /index.cfm$uri last;
        rewrite ^ /index.cfm last;
        return 404;
    }

    location ~ \.(?:cfm|cfc)\b {
        proxy_http_version  1.1;
        proxy_set_header    Connection "";
        proxy_set_header    Host                $host;
        proxy_set_header    X-Forwarded-Host    $host;
        proxy_set_header    X-Forwarded-Server  $host;
        proxy_set_header    X-Forwarded-For     $proxy_add_x_forwarded_for;     ## CGI.REMOTE_ADDR
        proxy_set_header    X-Forwarded-Proto   $scheme;                        ## CGI.SERVER_PORT_SECURE
        proxy_set_header    X-Real-IP           $remote_addr;
        expires             epoch;

        proxy_pass  http://cfml-in-docker.lucee:8888/public$uri$is_args$args;
    }

    location ~ /\.ht {
        deny all;
    }
}

Righto, where's me shooting game?

--
Adam

Sunday, 25 April 2021

Misc changes to environment for my ongoing Docker / Lucee / CFWheels series

G'day

This will be a bit of a scrappy article just summarising some changes to my project environment since the last article in this series on Lucee / CFWheels / Docker; "Adding TestBox, some tests and CFConfig into my Lucee container". By the end of that article I'd got Nginx proxying calls to Lucee, and some tests to verify its integrity and my expectations of how it ought to be working. I'm about to continue with an article about getting CFWheels to work (URL TBC), but before that - and for the ake of full disclosure - I'll detail these wee changes I've made.

It can connect Lucee to a MariaDB database and fetch records

The test summarises the aim here. /test/integration/TestDatabaseConnection.cfc:

component extends=testbox.system.BaseSpec {

    function run() {
        describe("Tests we can connect to the database", () => {
            it("can retrieve test records", () => {
                expectedRecords = queryNew("id,value", "int,varchar", [
                    [101, "Test row 1"],
                    [102, "Test row 2"]
                ])

                actualRecords = queryExecute("SELECT id, value FROM test ORDER BY id")
                
                expect(actualRecords).toBe(expectedRecords)
            })
        })
    }
}

Note that this filed under test/integration because it's testing the integration between Lucee and the DB, rather than any business logic.

I've aded some config to the test suite's Application.cfc too:

component {

    this.mappings = {
        "/cfmlInDocker/test" = expandPath("/test"),
        "/testbox" = expandPath("/vendor/testbox")
    }

    this.localmode = "modern"

    this.datasources["cfmlInDocker"] = {
        type = "mysql",
        host = "database.backend",
        port = 3306,
        database = "cfmlindocker",
        username = "cfmlindocker",
        password = server.system.environment.MYSQL_PASSWORD,
        custom = {
            useUnicode = true,
            characterEncoding = "UTF-8"
        }
    }
    this.datasource = "cfmlInDocker"
}

One key thing to note here is that I am setting this.localmode in here. Previous I was setting this in Lucee's global config via CFConfig, but Zac Spitzer dropped me a line and pointed out it could be set at runtime in Application.cfc. This is a much more elegant approach, so I'm running with it.

Other than that I'm setting a data source. Note I'm picking up the password from the environment, not hard-coding it. This is passed by the docker-compose.yml file:

lucee:
    build:
        context: ./lucee
        args:
            - LUCEE_PASSWORD=${LUCEE_PASSWORD}
    environment:
        - MYSQL_PASSWORD=${MYSQL_PASSWORD}

For the implementation of this requirement I've added a Docker container for MariaDB, added a test table into it and tested that Lucee can read data from it. This was all straight forward. Here are the file changes:

/docker/mariadb/Dockerfile:

FROM mariadb:latest

COPY ./docker-entrypoint-initdb.d/ /docker-entrypoint-initdb.d/
COPY ./conf/logging.cnf /etc/mysql/conf.d/logging.cnf
RUN chmod -R 644 /etc/mysql/conf.d/logging.cnf

CMD ["mysqld"]

EXPOSE 3306

Nothing mysterious there. I'm using the entrypoint to create the DB table and populate it (docker-entrypoint-initdb.d/1.createAndPopulateTestTable.sql):

USE cfmlindocker;

CREATE TABLE test (
    id INT NOT NULL,
    value VARCHAR(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL,

    PRIMARY KEY (id)
) ENGINE=InnoDB;

INSERT INTO test (id, value)
VALUES
    (101, 'Test row 1'),
    (102, 'Test row 2')
;

ALTER TABLE test MODIFY COLUMN id INT auto_increment;

I'm also moving logging to a different directory so I can see them on my host machine (via conf/logging.cnf):

[mysqld]
log_error = /var/log/mariadb/error.log

This is all wired-together in docker-compose.yml

mariadb:
    build:
        context: ./mariadb
    environment:
        - MYSQL_ROOT_PASSWORD=${DATABASE_ROOT_PASSWORD}
        - MYSQL_DATABASE=${MYSQL_DATABASE}
        - MYSQL_USER=${MYSQL_USER}
        - MYSQL_PASSWORD=${MYSQL_PASSWORD}
    ports:
        - "3306:3306"
    volumes:
        - mysqlData:/var/lib/mariadb
        - ./mariadb/root_home:/root
        - ../var/log:/var/log
    stdin_open: true
    tty: true
    networks:
        backend:
            aliases:
                - database.backend

volumes:
    mysqlData:

Note that I am sticking the DB data into a Docker volume instead of in a volume from my host machine. This means I need to take some care if I ever get around to adding non-test data into it, but for the time being it saves cluttering up my host machine with DB files, plus it's easier during initial configuration to completely reset the DB. It's easy enough to change later on when I need to.

I'm setting some of those magic environment variable in .env:

COMPOSE_PROJECT_NAME=cfml-in-docker
MYSQL_DATABASE=cfmlindocker
MYSQL_USER=cfmlindocker

# the following are to be provided to `docker-compose up`
LUCEE_PASSWORD=
DATABASE_ROOT_PASSWORD=
MYSQL_PASSWORD=

And the passwords when I build the containers:

adam@DESKTOP-QV1A45U:/mnt/c/src/cfml-in-docker/docker$ DATABASE_ROOT_PASSWORD=123 MYSQL_PASSWORD=1234 LUCEE_PASSWORD=12345 docker-compose up --build --detach --force-recreate

It got rid of CFConfig

Both the Lucee settings I needed to change with CFConfig before hand can be done natively with Lucee, so I didn't need CFConfig any more. I might need it again later, in which case I will re-install it. But for now it's dead-weight.

RUN box install commandbox-cfconfig
RUN box cfconfig set localScopeMode=modern to=/opt/lucee/web
RUN box cfconfig set adminPassword=${LUCEE_PASSWORD} to=/opt/lucee/web
RUN echo ${LUCEE_PASSWORD} > /opt/lucee/server/lucee-server/context/password.txt # this handles the passwords for both server and web admins

It can run the tests from the shell

Running TestBox's tests in a browser is all very pretty, but not very practical. Fortunately I read the TestBox docs some more and found out how to run them from the shell. They show how to run it from within CommandBox's own special shell here in "TestBox integration › Test runner", but that's weird and no help to me. However I finally twigged that it seems that whatever one might do within the special shell, one can also call from the normal shell via the box command. All I needed to do to enable this was to tell CommandBox how to run the tests in docker/lucee/box.json, which is used by CommandBox in the docker/lucee/Dockerfile:

{
    "devDependencies":{
        "testbox":"^4.2.1+400"
    },
    "installPaths":{
        "testbox":"vendor/testbox/"
    },
    "testbox":{
        "runner":"http://localhost:8888/test/runTests.cfm"
    }
}
COPY ./box.json /var/www/box.json
RUN mkdir -p /var/www/vendor
RUN box install

This has the benefit that the test run doesn't simply return a 200-OK all the time whether tests all passed or not; it exits with a 1 if there's any test failures. So it's usable in a CI/CD situation.

It resolves the slowness with CommandBox

In the previous article I observed that running stuff with CommandBox seemed to have about a 45sec overhead for any action. I tracked this down to the fact that I have my /root/home directory as a volume from my host machine so my various shell histories persist across container rebuilds. And I then realised that CommandBox dumps a whole lot of shite in that directory which it needs to load every time it runs. Because of the shenanigans Docker needs to do when bridging from its file system across to WSL across to the native Windows file systems, these operations are S-L-O-W. OK for a few files. Not OK for stacks of them.

Fortunately CommandBox can be configured to put its temp files elsewhere, so I have configured it to put them in /var/temp instead. As they regenerate if they are missing, this seems like the best place for them. It also prevents clutter leaking out of my container and onto my host machine. This is done via a commandbox.properties file:

commandbox_home=/var/tmp/commandbox

Which I copy into place in the Dockerfile. CommandBox picks it up automatically when I place it there:

COPY ./commandbox.properties /usr/local/bin/commandbox.properties

Good stuff. Now it only takes about 5sec for box to start doing anything, which is fine.

It no longer has the problem with path_info

I covered the shortfall in how Lucee handles path_info in "Repro for Lucee weirdness". I've managed to work around this. Kind of. In a way that solves the problem for this project anyhow.

Well I guess really it is just "learning to live with it". I've done some other experimentation with CFWheels, and all it uses path_info for is indeed to implement semi-user-friendly URLs tacked on to index.cfm. It has no need for any other .cfm file to use its path_info, so the default mappings are actually fine as they are.

However it occurred to me when I was configuring Nginx to do its part of the user-friendly URLs that all requests coming into Lucee from the web server will land in /public, so I could just put in a servlet mapping for the index.cfm in that directory (from web.xml):

<servlet-mapping>
    <servlet-name>CFMLServlet</servlet-name>
    <url-pattern>*.cfm</url-pattern>
    <url-pattern>*.cfml</url-pattern>
    <url-pattern>*.cfc</url-pattern>
    <url-pattern>/index.cfm/*</url-pattern>
    <url-pattern>/index.cfc/*</url-pattern>
    <url-pattern>/index.cfml/*</url-pattern>

    <url-pattern>/public/index.cfm/*</url-pattern>
</servlet-mapping>

One might think that instead of using <url-pattern>/public/index.cfm/*</url-pattern>, I might be able to just specify a match for the entire directory, like this: <url-pattern>/public/*</url-pattern>. From a POV of Tomcat's expectations this ought to be good enough, but from Lucee's perspective it doesn't see that as a "anything in that directory", it's expecting that pattern to be a file that matches a CFML file, so when I tried that I just got an error along the lines of "/public is a directory". Ah well. FWIW, ColdFusion said pretty much the same thing.

One downside to this is that I cannot work out how to add a servlet mapping just for this Lucee application, so I need to replace the entire Tomcat web.xml file, with another one with just one additional line (the original file is 4655 lines long). This is less than ideal, and I've followed it up on the Lucee Slack channel. I just copy the file over in the Dockerfile:


COPY ./root_home/.bashrc /root/.bashrc
COPY ./root_home/.vimrc /root/.vimrc
COPY ./web.xml /usr/local/tomcat/conf/web.xml

I had to rename my test file to index.cfm (so this means the test will need to just go once I install CFWheels which needs that file), but for now I was able to test the change:


it("passes URL path_info to Lucee correctly", () => {
    testPathInfo = "/additional/path/info/"

    http url="http://cfml-in-docker.frontend/index.cfm#testPathInfo#" result="response";

    expect(response.status_code).toBe(200, "HTTP status code incorrect")
    expect(response.fileContent.trim()).toBe(testPathInfo, "PATH_INFO value was incorrect")
})
<cfoutput>#CGI.path_info#</cfoutput>

Oh! And the Nginx changes! docker/nginx/sites/default.conf:

location / {
    try_files $uri $uri/ =404;
    try_files $uri $uri/ @rewrite;
}

location @rewrite {
    rewrite ^/(.*)? /index.cfm$request_uri last;
    rewrite ^ /index.cfm last;
}

(Thanks to the ColdBox docs for those)

It no longer needs PHP to test things

I'm happy that TestBox is working well enough now that I don't need to test things with PHPUnit, and that's all the PHP container was for, so I've removed all that stuff.


That's it. In the next article I shall continue from here, and get CFWheels set up in a waythat doesn't require the entire application being a) messed in with my own code; b) in a web browsable directory. Stay tuned…

Righto.

--
Adam

Monday, 19 April 2021

Adding TestBox, some tests and CFConfig into my Lucee container

G'day:

On Fri/Sat (it's currently Sunday evening, but I'll likely not finish this until Monday now) I started looking at getting some CFML stuff running on Lucee in a Docker container (see earlier/other articles in this series: Lucee/CFWheels/Docker series). If you like you can read about that stuff: "Using Docker to strum up an Nginx website serving CFML via Lucee" and "Repro for Lucee weirdness". This article resumes from where I got to with the former one, so that one might be good for some context.

Full disclosure: I spent all today messing around in a spike: experimenting with stuff, and now am finally happy I have got something to report back on, so I have rolled-back the spike and am going to do the "production" version of it via TDD again. I just say this - and it's not the first time - if yer doing TDD it's OK to spike-out and do a bunch of stuff to work out how to do things without testing every step. Especially if yer like me and start from a position of having NFI what you need to do. However once yer done: revert everything and start again, testing-first as you go. What I've done here is save all my stuff in a branch, and now I'm looking at a diff of that and main, as a reference to what I actually need to do, and what is fluff that represents a dead end, or something I didn't need to do anyhow, or whatever.

It needs to only expose public stuff to the public

As per the article subject line, today I'm gonna install a bit more tooling and get me in a better position to do some dev work. The first thing I noticed is that as things stand, the Nginx wesbite is serving everything in the Lucee application root (/var/www), whereas that directory is going to be a home directory for the application code, test code and third-party apps, so I don't want that browsable. I'm going to shift things around a bit. A notional directory structure would be along these lines:

root
├── public
│   ├── css
│   ├── images
│   ├── js
│   ├── Application.cfc
│   ├── favicon.ico
│   └── index.cfm
├── src
│   └── Application.cfc
├── test
└── vendor
    ├── cfwheels
    └── testbox

I've taken a fairly Composer / PHP approach there, but I could equally follow a more Java-centric approach to the directory structure:

root
├── com
│   └── ortussolutions
│       └── testBox
├── me
│   └── adamcameron
│       └── cfmlInDocker
│           ├── src
│           │   └── Application.cfc
│           └── test
├── org
│   └── cfwheels
│       └── cfwheels
└── public
    ├── css
    ├── images
    ├── js
    ├── Application.cfc
    ├── favicon.ico
    └── index.cfm

The point being: the website directory and my code and other people's code should be kept well away from one another. That's just common-sense.

Anyway, back to the point. Whichever way I organise the rest of things, only stuff that is supposed to be browsed-to should be browsable. Everything else should not be. So I'm gonna move the website's docroot, as well as the files that need to be served. This is just a "refactoring" exercise, so no tests should change here. We just want to make sure they still all pass.

This just means some changes to docker-compose.yml:

lucee:
    build:
        context: ./lucee
    volumes:
    	- ../public:/var/www
        - ../public:/var/www/public
        - ../root/src:/var/www/src
        - ../test:/var/www/test
        - ../var/log/tomcat:/usr/local/tomcat/log
        - ../var/log/lucee:/opt/lucee/web/logs
        - ./lucee/root_home:/root

And the website config (docker/nginx/sites/default.conf):

location ~ \.(?:cfm|cfc) {
    # ...

    proxy_pass  http://cfml-in-docker.lucee:8888$fastcgi_script_name$is_args$args;
    proxy_pass  http://cfml-in-docker.lucee:8888/public$fastcgi_script_name$is_args$args;
}

Once a rebuild the containers, I get two failing tests. Oops: I did not expect that. What's going on? Checking the front-end, the public-facing website still behaves the same. So… erm …?

One thing that didn't occur to me when doing this change is that a couple of the tests are hitting the internal Lucee website (reminder: the public-facing website for me is http://cfml-in-docker.frontend/ and the internal Lucee web site is http://cfml-in-docker.lucee:8888/). And that internal website still points to /var/www/, so where previously I'd access http://cfml-in-docker.lucee:8888/remoteAddrTest.cfm, now the URL for the backend site is be http://cfml-in-docker.lucee:8888/public/remoteAddrTest.cfm. This is by design (kinda, just… for now), but I forgot about this when I made the change.

This means to me that my change is not simply a refactoring: therefore I need to start with a failing tests. I roll back my config changes, fix the tests so they hit http://cfml-in-docker.lucee:8888/public/gdayWorld.cfm and http://cfml-in-docker.lucee:8888//public/remoteAddrTest.cfm respectively, and watch them fail. Good. Now I roll forward my config changes again and see the tests pass: cool. Job done.

Later when I'm reconfiguring things I might remap it to /var/www/public, if I can work out how to do that without hacking Tomcat config files too much. But remember the test case here: It needs to only expose public stuff to the public. And we've achieved that. Let's not worry about a test case we don't need to address for now. Moving on…

It can run tests with TestBox

Currently I am running my tests via a separate container running PHP and PHPUnit. This has been curling the website to test Nginx and Lucee behaviour. Now that I have Lucee working, I can shift the tests to TestBox, which is - as far as I know - the current state of the art when it comes to testing CFML code. It provides both xUnit and Jasmine-style testing syntax.

The test for this is going to be a "physician heal thyself" kind of affair. I'm going to write a TestBox test. Once I can run it and it doesn't just go splat: job done. The test is simply this:

component extends=testbox.system.BaseSpec {

    function run() {
        describe("Baseline TestBox tests", () => {
            it("can verify that true is, indeed, true", () => {
                expect(true).toBe(true)
            })
        })
    }
}

Testbox seems to be web-based. I'd much prefer just running my tests from the shell like I would any other testing framework I've used in the last 7-8 years, but CFML has much more "it's aimed at websites, so evertything is implemented as a website" mentality (hammers and nails spring to mind here). So be it I guess. I do see that TestBox does have the ability to integrate with Ant, but that seems to be via an HTTP request as well. Hrm. What I know is I can't simply do something like testbox /path/to/my/tests or similar. What I do need to do is write a small runner file (runTests.cfm), which I then browse to:

<cfscript>
    testBox = new testbox.system.TestBox(directory="cfmlInDocker.test")
    result = testBox.run(
        reporter = "testbox.system.reports.SimpleReporter"
    )
    writeOutput(result)
</cfscript>

To use that testbox.system and cfmlInDocker.test paths, I need to define mappings for them at application level (ie: not in the same file that uses it, but a different unrelated file, Application.cfc):

component {
    this.mappings = {
        "/cfmlInDocker/test" = expandPath("/test"),
        "/testbox" = expandPath("/vendor/testbox")
    }
}

And when I browse to that, I get a predictable error:

Let's call that our "failing test".

OK so right, we install TestBox from ForgeBox (think packagist.org or npmjs.com). And to install stuff from ForgeBox I need CommandBox. And that is pretty straight forward; just a change to my Lucee Dockerfile:

FROM lucee/lucee:5.3

RUN apt-get update
RUN apt-get install vim --yes

COPY ./root_home/.bashrc /root/.bashrc
COPY ./root_home/.vimrc /root/.vimrc

WORKDIR  /var/www

RUN curl -fsSl https://downloads.ortussolutions.com/debs/gpg | apt-key add -
RUN echo "deb https://downloads.ortussolutions.com/debs/noarch /" | tee -a /etc/apt/sources.list.d/commandbox.list
RUN apt-get update && apt-get install apt-transport-https commandbox --yes
RUN echo exit | box
EXPOSE 8888

That last step there is because CommandBox needs to configure itself before it works, so I might as well do that when it's first installed.

Once I rebuild the container with that change, we can get CommandBox to install TestBox for us:

root@b73f0836b708:/var/www# box install id=testbox directory=vendor savedev=true
√ | Installing package [forgebox:testbox]
   | √ | Installing package [forgebox:cbstreams@^1.5.0]
   | √ | Installing package [forgebox:mockdatacfc@^3.3.0+22]

root@b73f0836b708:/var/www#
root@b73f0836b708:/var/www#
root@b73f0836b708:/var/www# ll vendor/
total 12
drwxr-xr-x 3 root root 4096 Apr 19 09:23 ./
drwxr-xr-x 1 root root 4096 Apr 19 09:23 ../
drwxr-xr-x 9 root root 4096 Apr 19 09:23 testbox/
root@b73f0836b708:/var/www#

Note that commandbox is glacially slow to do anything, so be patient rather than be like me going "WTH is going on here?" Check this out:

root@b73f0836b708:/var/www# time box help

**************************************************
* CommandBox Help
**************************************************

Here is a list of commands in this namespace:

// help stuff elided…

To get further help on any of the items above, type "help command name".

real    0m48.508s
user    0m10.298s
sys     0m2.448s
root@b73f0836b708:/var/www#

48 bloody seconds?!?!. Now… fine. I'm doing this inside a Docker container. But even still. Blimey fellas. This is the equivalent for composer:

root@b21019120bca:/usr/share/cfml-in-docker# time composer --help
Usage:
  help [options] [--] [<command_name>]

// help stuff elided…


To display the list of available commands, please use the list command.

real    0m0.223s
user    0m0.053s
sys     0m0.035s
root@b21019120bca:/usr/share/cfml-in-docker#

That is more what I'd expect. I suspect they are strumming up a CFML server inside the box application to execute CFML code to do the processing. Again: hammer and nails eh? But anyway, it's not such a big deal. The important thing is: did it work?

Yes it bloody did! Cool! Worth the wait, I say.

I still need to find a way to run it from the shell, and I also need to work out how to integrate it into my IDE, but I have a minimum baseline of being able to run tests now, so that is cool.

The installation process also generated a box.json file, which is the equivalent of a composer.json / packages.json file:

root@b73f0836b708:/var/www# cat box.json
{
    "devDependencies":{
        "testbox":"^4.2.1+400"
    },
    "installPaths":{
        "testbox":"vendor/testbox/"
    }
}

It doesn't seem to have the corresponding lock file though, so I'm wondering how deployment works. The .json dictates what could be installed (eg: for testbox it's stating it could be anything above 4.2.1+400 but less than 5.0), but there's nothing controlling what is installed. EG: specifically 4.2.1+400. If I run this process tomorrow, I might get 4.3 instead. It doesn't matter so much with dev dependencies, but for production dependencies, one wants to make sure that whatever version is being used on one box will also be what gets installed on another box. Which is why one needs some lock-file concept. The Composer docs explain this better than I have been (and NPM works the same way). Anyway, it's fine for now.

Now that I have the box.json file, I can simply run box install in my Dockerfile:

# …
WORKDIR  /var/www

RUN curl -fsSl https://downloads.ortussolutions.com/debs/gpg | apt-key add -
RUN echo "deb https://downloads.ortussolutions.com/debs/noarch /" | tee -a /etc/apt/sources.list.d/commandbox.list
RUN apt-get update && apt-get install apt-transport-https commandbox --yes
RUN echo exit | box

COPY ./box.json /var/www/box.json
RUN mkdir -p /var/www/vendor
RUN box install

EXPOSE 8888

It runs all the same tests via TestBox as it does via PHPUnit

I'm not going to do some fancy test that actually tests that my tests match some other tests (I'm not that retentive about TDD!). I'm just going to implement the same tests I've already got on PHPUnit in TestBox. Just as some practise at TestBox really. I've used it in the past, but I've forgotten almost everything I used to know about it.

Actually that was pretty painless. I'm glad I took the time to properly document CFScript syntax a few years ago, as I'd forgotten how Railo/Lucee handled tags-in-script, and the actual Lucee docs weren't revealing this to me very quickly. That was the only hitch I had along the way.

All the tests are variations on the same theme, so I'll just repeat one of the CFCs here (NginxProxyToLuceeTest.cfc):

component extends=testbox.system.BaseSpec {

    function run() {
        describe("Tests Nginx proxies CFML requests to Lucee", () => {
            it("proxies a CFM request to Lucee", () => {
                http url="http://cfml-in-docker.frontend/gdayWorld.cfm" result="response";

                expect(response.status_code).toBe( 200, "HTTP status code incorrect")
                expect(response.fileContent.trim()).toBe( "G'day world!", "Response body incorrect")
            })

            it("passes query values to Lucee", () => {
                http url="http://cfml-in-docker.frontend/queryTest.cfm?testParam=expectedValue" result="response";

                expect(response.status_code).toBe( 200, "HTTP status code incorrect")
                expect(response.fileContent.trim()).toBe( "expectedValue", "Query parameter value was incorrect")
            })

            it("passes the upstream remote address to Lucee", () => {
                http url="http://cfml-in-docker.lucee:8888/public/remoteAddrTest.cfm" result="response";
                expectedRemoteAddr = response.fileContent

                http url="http://cfml-in-docker.lucee:8888/public/remoteAddrTest.cfm" result="testResponse";
                actualRemoteAddr = testResponse.fileContent

                expect(actualRemoteAddr).toBe(expectedRemoteAddr, "Remote address was incorrect")
            })
        })
    }
}

The syntax for making an http request uses that weirdo syntax that is neither fish nor fowl (and accordingly confuses Lucee itself as to what's a statement and what isn't, hence needing the semi-colon), but other than that it's all quite tidy.

And evidence of them all running:

I can get rid of the PHP container now!

It uses CFConfig to make some Lucee config tweaks

An observant CFMLer will notice that I did not var my variables in the code above. To non-CFMLers: one generally needs to actively declare a variable as local to the function its in (var myLocalVariable = "something"), otherwise without that var keyword it's global to the object it's in. This was an historically poor design decision by Macromedia, but we're stuck with it now. Kinda. Lucee has a setting such that the var is optional. And I've switched this setting on for this code.

Traditionally settings like this need to be managed through the Lucee Administrator GUI, but I don't wanna have to horse around with that: it's a daft way of setting config. There's no easy out-of-the-box way of making config changes like this outside the GUI, but there's a tool CFConfig that let's me do it with "code". Aside: why is this not called ConfigBox?

Before I do the implementation, I can actually test for this:

component extends=testbox.system.BaseSpec {

    function run() {
        describe("Tests Lucee's config has been tweaked'", () => {
            it("has 'Local scope mode' set to 'modern'", () => {
                testVariable = "value"

                expect(variables).notToHaveKey("testVariable", "testVariable should not be set in variables scope")
                expect(local).toHaveKey("testVariable", "testVariable should be set in local scope")
            })
        })
    }
}

Out of the box that first expectation will fail. Let's fix that.

Installing CFConfig is done via CommandBox/Forgebox, and I can do that within the Dockerfile:

RUN box install commandbox-cfconfig

Then I can make that setting change, thus:

RUN box cfconfig set localScopeMode=modern to=/opt/lucee/web

I'm going to do one more tweak whilst I'm here. The Admin UI requires a coupla passwords to be set, and by default one needs to do the initial setting via putting it in a file on the server and importing it. Dunno what that's all about, but I'm not having a bar of it. We can sort this out with the Dockerfile and CFConfig too:

FROM lucee/lucee:5.3

ARG LUCEE_PASSWORD

# a bunch of stuff elided for brevity…

RUN box install commandbox-cfconfig
RUN box cfconfig set localScopeMode=modern to=/opt/lucee/web
RUN box cfconfig set adminPassword=${LUCEE_PASSWORD} to=/opt/lucee/web # for web admin
RUN echo ${LUCEE_PASSWORD} > /opt/lucee/server/lucee-server/context/password.txt # for server admin (actually seems to deal with both, now that I check)

EXPOSE 8888

That argument is passed by docker-compose, via docker-compose.yml:

lucee:
    build:
        context: ./lucee
        args:
            - LUCEE_PASSWORD=${LUCEE_PASSWORD}

And that in turn is passed-in via the shell when the containers are built:

adam@DESKTOP-QV1A45U:/mnt/c/src/cfml-in-docker/docker$ LUCEE_PASSWORD=12345678 docker-compose up --build --detach --force-recreate

I'd rather use CFConfig for both the passwords, but I could not find a setting to set the server admin one. I'll ask the CFConfig bods. I did find a setting to just disable the login completely (adminLoginRequired), but I suspect that setting is only for ColdFusion, not Lucee. It didn't work for me on Lucee anyhow.

It has written enough for today

I was gonna try to tackle getting CFWheels installed and running in this exercise too, but this article is already long enough and this seems like a good place to pause. Plus I've just spotted someone being wrong on the internet, and I need to go interject there first.

Righto.

--
Adam