Skip to content

maaislam/abtestrig

Repository files navigation

abtestrig

Build tool for client-side A/B experiments. Works with Optimizely, Convert, VWO, Adobe Target, Dynamic Yield, Monetate, and Intelligems — or without a platform entirely.


Why this exists

Most CRO build tools make you paste an experiment ID into a loader script every time you switch experiments. abtestrig doesn't. Paste scriptLoader.js into your browser extension once. After that, running abtestrig watch tells the extension what's active automatically — including which port the server landed on.

Other differences from the Gulp/Rollup boilerplate that circulates in the industry:

  • esbuild instead of Rollup + Babel. Builds that used to take 8–15 seconds finish in under 200ms.
  • WebSocket push instead of polling Last-Modified. CSS swaps the moment a file saves, without a page reload.
  • Port discovery in scriptLoader. If port 3000 is busy and the server moves to 3001, the extension finds it automatically.
  • esbuild define for build-time constants. shared.js never gets modified on disk, so git never shows a dirty file after starting the dev server.
  • One command (abtestrig watch) starts the file watcher, dev server, and WebSocket server together.
  • Stack mode for interaction testing. Run multiple experiments on the same page in a deterministic order to catch CSS / DOM / JS conflicts before they hit production.

Install

git clone https://github.com/maaislam/abtestrig
cd abtestrig
npm install

That's it — you can already run any command as node cli.js <command> from inside the project folder, or via npm scripts:

npm run watch  -- --cn=Screwfix --fn=SCR001
npm run build  -- --cn=Screwfix --fn=SCR001
npm run create
npm run serve

(The -- separator passes the flags through to the script.)

Optional: make abtestrig work from any folder

After this step, you can run abtestrig watch ... from any directory — no need to cd into the project folder first.

Find your project path first. A few of the steps below need the absolute path to wherever you cloned this repo. From inside the abtestrig folder:

  • PowerShell — run $PWD.Path (e.g. C:\Users\Alice\dev\abtestrig)
  • Bash / Git Bash — run pwd (e.g. /c/Users/Alice/dev/abtestrig)

Copy that string and substitute it wherever you see <your-abtestrig-path> below.

Easiest path: npm link

macOS / Linux — open a terminal and run:

cd <your-abtestrig-path>          # e.g. /Users/alice/dev/abtestrig
npm link

Both abtestrig and cro are now global commands. Done.

Windowsnpm link creates a symbolic link under your global node_modules folder, and Windows blocks creating symlinks by default. You have two ways around this:

1. Enable Developer Mode (one-time setup, recommended)

  1. Press Windows key, open Settings.

  2. Go to Privacy & securityFor developers.

  3. Turn Developer Mode ON.

  4. Open a fresh PowerShell window, then:

    cd <your-abtestrig-path>          # e.g. C:\Users\Alice\dev\abtestrig
    npm link

2. Run PowerShell as Administrator (one-off, no system change)

  1. Press the Windows key, type powershell.

  2. Right-click Windows PowerShell in the search results → Run as administrator.

  3. Click Yes on the UAC permission prompt.

  4. In the elevated window (note: it opens in C:\Windows\System32 by default — you must cd into your project folder):

    cd <your-abtestrig-path>          # e.g. C:\Users\Alice\dev\abtestrig
    npm link
  5. Close the admin window. You don't need to keep it open.

After either option, open any normal (non-admin) shell and abtestrig --help should work from anywhere.

If npm link fails: shell function fallback

If you get errors like EPERM / UNKNOWN, or your work computer's IT policy blocks Developer Mode and admin rights, use a shell function instead. No admin needed, no symlinks involved — just one line in your shell's startup file.

PowerShell
  1. Open a regular PowerShell window (admin not needed).

  2. Run notepad $PROFILE to open your profile file. If Notepad asks "Do you want to create a new file?", click Yes.

  3. Paste this line at the bottom of the file, replacing <your-abtestrig-path> with the path you copied earlier:

    function abtestrig { node <your-abtestrig-path>\cli.js @args }   # e.g. C:\Users\Alice\dev\abtestrig
  4. Save (Ctrl+S) and close Notepad.

  5. Close and reopen PowerShell. Test with abtestrig --help.

If you see an "execution of scripts is disabled on this system" error, Windows is blocking your profile from running. Run this command once (in any PowerShell window) to allow your own profile script to load:

Set-ExecutionPolicy -Scope CurrentUser RemoteSigned

Then close and reopen PowerShell again.

Bash / Git Bash

  1. Open Git Bash (or any bash terminal).

  2. Edit your bashrc — nano ~/.bashrc if you're comfortable with nano, or code ~/.bashrc if you have VS Code on your PATH. Either creates the file if it doesn't exist.

  3. Add this line, replacing <your-abtestrig-path> with the path you copied from pwd:

    abtestrig() { node <your-abtestrig-path>/cli.js "$@"; }   # e.g. /c/Users/Alice/dev/abtestrig

    On Git Bash specifically, Windows paths use a Unix-style format — C:\Users\Alice\dev\abtestrig becomes /c/Users/Alice/dev/abtestrig (lowercase drive letter, forward slashes). The pwd command above already gives it to you in this format.

  4. Save and close.

  5. Run source ~/.bashrc to load it in the current session, or just close and reopen the terminal. Test with abtestrig --help.


Getting started

1. Scaffold a new experiment

abtestrig create

You'll be prompted for client name, experiment ID, and variation. The template is picked automatically based on the client name — override with --template if needed.

2. Start watching

abtestrig watch --cn=Screwfix --fn=SCR001

This starts everything: file watcher, dev server, and WebSocket server. If port 3000 is busy it tries 3001, 3002 and so on, and tells you which one it landed on.

3. Connect the browser extension Install User JS & CSS in Chrome. Paste the contents of scriptLoader.js into the extension for the site you're testing on.

That's it. Open the client's site — your experiment loads automatically. Save a file — changes appear within milliseconds, CSS without a page reload. Switch to a different experiment by running a new abtestrig watch — the extension picks up the change on its own.


Commands

abtestrig watch

The main development command. Starts everything together.

abtestrig watch --cn=<client> --fn=<experiment>

# Options
--variation <n>    Variation number (default: 1)
--port <n>         Preferred port (default: 3000, increments if busy)
--livecode         Suppress analytics events during live testing

abtestrig build

One-shot production build. Outputs minified files to dist/min/ — these are what go into the CRO platform.

abtestrig build --cn=<client> --fn=<experiment>

Prints file sizes and total build time on completion.

abtestrig create

Interactive experiment scaffold. Prompts for client name, experiment ID, and variation.

abtestrig create

# Skip the prompts
abtestrig create --cn=Screwfix --fn=SCR001

# Scaffold + register in a stack (prompts for stack name)
abtestrig create --cn=Screwfix --fn=SCR001 --stack

# Scaffold + register in a specific stack, no prompt
abtestrig create --cn=Screwfix --fn=SCR001 --stack=interaction-test

When the destination already exists, prompts whether to keep the existing files, overwrite, or cancel. When --stack is used, after the scaffold you'll be asked if you want to add another experiment to the same stack — loops until you say no.

abtestrig serve

Starts the dev server and WebSocket server without watching. Useful for QA on pre-built files.

abtestrig serve --port=3000

abtestrig stack

Run several experiments side-by-side on the same page. See Interaction testing with stacks below.

abtestrig stack stacks/<name>.json --port=3000

Interaction testing with stacks

Running two experiments at once on a real page is the only reliable way to catch the bugs that appear when both ship: a CSS selector that matches both variations, a DOM element one experiment adds and another modifies, a JS poller that wins a race against another experiment's mutation. Stack mode reproduces production-day combinations on demand.

A stack file

{
  "name": "homepage-conflict-test",
  "experiments": [
    { "client": "Screwfix", "fn": "SCR001", "variation": "1" },
    { "client": "Screwfix", "fn": "SCR002", "variation": "control" },
    { "client": "Screwfix", "fn": "SCR003", "variation": "1", "watch": true }
  ]
}
  • Stack files live in stacks/. One JSON per scenario.
  • Order in experiments[] = injection order in the browser. CSS cascade and JS execution follow this order.
  • Each entry has its own variation.
  • "watch": true opts that entry into live reload. Default is frozen (built once, no watcher). Typically the experiment in active development gets watch: true; reference experiments stay frozen.

Running it

abtestrig stack stacks/homepage-conflict-test.json

Each experiment is built serially; server-dist/config.json is rewritten to contain the full list. scriptLoader fetches all of them in parallel from server-dist/, then injects in the declared order. JS injection via <script> is synchronous, so the first entry's code runs to completion before the next one's tag is parsed — execution order is deterministic.

The banner reports which entries are watching vs frozen:

✓ Screwfix/SCR001 variation 1 · frozen
✓ Screwfix/SCR002 variation control · frozen
✓ Screwfix/SCR003 variation 1 · watching

Building a stack interactively

Easiest way to make a new stack: pair cro create --stack=<name> with an existing or new stack name. When the stack file doesn't exist yet, you'll be walked through:

  1. Multi-select prompt of other experiments in clients/ to include.
  2. Per-experiment variation prompt.
  3. Where to place the newly-scaffolded experiment (FIRST or LAST in the order).
  4. Looped "Add another experiment to this stack?" until you stop.

The resulting JSON marks the actively-developed experiments as "watch": true and the reference ones as "watch": false.


Project structure

clients/
  Screwfix/
    SCR001/
      src/
        triggers.js        ← entry point (called from scriptLoader once injected)
        shared.js          ← build-time constants (__ID__, __VARIATION__ etc.)
        experiment.scss
        _variables.scss
        lib/
          experiment.js    ← activate() — your experiment code
          helpers/
            utils.js       ← client-specific helpers (Screwfix uses this layout)
      dist/
        SCR001.js          ← unminified (for debugging)
        SCR001.css
        min/
          SCR001.min.js    ← paste this into the platform
          SCR001.min.css
lib/                       ← @lib/* — global helper library shared across clients
  dom.js
  url.js
  events.js
  storage.js
  utils.js
  index.js                 ← barrel re-export
server-dist/               ← served by the dev server (gitignored)
  config.json              ← active experiment(s) — read by scriptLoader
  SCR001.js
  SCR001.css
stacks/                    ← interaction-test scenarios
  homepage-conflict.json
templates/
  core/                    ← default template
templates_custom/
  example/                 ← documented starting point for new client templates
  screwfix/
  travisperkins/
  avon/
  ...

How scriptLoader works

scriptLoader.js connects to the WebSocket server and stays connected. The server sends three message types:

Type When What scriptLoader does
experiment-changed On connect, or when abtestrig watch / abtestrig stack starts Fetches and injects JS + CSS for all listed experiments, in order
js-updated After a JS rebuild (carries id in stack mode) Re-fetches and re-injects the affected experiment's JS
css-updated After a CSS rebuild (carries id in stack mode) Swaps that experiment's stylesheet without a page reload

scriptLoader reads config.json to discover which experiment(s) to load. It accepts two shapes:

// Single experiment (watch mode)
{ "id": "SCR001", "variation": "1", "client": "Screwfix", "port": 3000 }

// Stack (stack mode)
{ "stack": "homepage-conflict-test", "port": 3000, "experiments": [
    { "id": "SCR001", "client": "Screwfix", "variation": "1" },
    { "id": "SCR002", "client": "Screwfix", "variation": "control" }
]}

There is no experiment ID hardcoded in scriptLoader.js itself — it learns the active set entirely from config.json + the WebSocket message. Paste it into your browser extension once and forget it.

Per-experiment DOM slots. scriptLoader gives each experiment its own <style id="cro-dev-css-{id}"> and <script id="cro-dev-js-{id}"> element. Live-reloading one experiment in a stack only swaps that experiment's slot — the others stay untouched.

Port discovery. On connect (and after every disconnect) scriptLoader probes ports 3000–3009 in parallel by fetching /config.json on each. It validates the response is a real abtestrig server (has id+client OR a non-empty experiments array), then connects to whichever responds first. The extension survives a server restart on a different port without any manual update.

If you regularly use a port outside 3000–3009, update PORT_MIN/PORT_MAX at the top of scriptLoader.js.


Global helper library (@lib)

Reusable helpers shared across all experiments live in /lib at the repo root. Experiments import them via the @lib alias:

import { pollerLite, waitForElement, obsIntersection } from '@lib/dom';
import { onUrlChange, matchRoute } from '@lib/url';
import { ensureTracking, fireEvent } from '@lib/events';
import { getCookie, setCookie, getStorage, setStorage } from '@lib/storage';
import { debounce, throttle, once } from '@lib/utils';

The alias is resolved at build time by an esbuild plugin in scripts/build.js, so there's no runtime cost — only the helpers you import end up in your bundle. Add new helpers by dropping a file into /lib; no build configuration changes needed.

See lib/README.md for the full export list and design notes.


Build-time constants

shared.js uses esbuild define constants rather than string tokens:

export default {
  ID:        __ID__,
  VARIATION: __VARIATION__,
  CLIENT:    __CLIENT__,
  LIVECODE:  __LIVECODE__,
};

esbuild replaces these at bundle time. The file on disk never changes — no dirty git state after starting the dev server.

@lib/events reads the same constants directly (no initEvents() call needed) — call ensureTracking(measurementId) once in your activate() and fireEvent(label) works from anywhere in the experiment.


Templates

templates/core

The default template, used when no client-specific template is found. Contains the minimum needed to get an experiment running: triggers.js, experiment.js, experiment.scss, and shared.js.

templates_custom/example

A documented starting point for new client templates. Includes commented-out examples of common patterns: SPA URL change handling, platform event tracking, DOM render delay activation, and breakpoint variables. Read the README inside that folder before creating a new client template.

Auto-detection

When you run abtestrig create, the template is chosen based on the client name:

Client Template
Screwfix templates_custom/screwfix
Travis Perkins, Howdens templates_custom/travisperkins
Avon templates_custom/avon
Boots templates_custom/boots
Homeserve templates_custom/homeserve
Flannels, SportsDirect, HouseOfFraser templates_custom/frasers
Everything else templates/core

Override with --template=<name> to use a specific template regardless of client name.

Adding a new client template

  1. Copy templates_custom/example to templates_custom/<clientname>
  2. Adjust the tracking setup, activation conditions, and any client-specific patterns
  3. Add the client name to the CLIENT_TEMPLATES map in scripts/create.js

Migrating from the old Gulp/Rollup tool

Two small changes per experiment:

In shared.js:

// before
ID: '{{ID}}',
VARIATION: '{{VARIATION}}',

// after
ID: __ID__,
VARIATION: __VARIATION__,

In _variables.scss, just remove any --ID-- / --VARIATION-- placeholder declarations. The build pipeline now prepends $id and $variation to your entry stylesheet automatically — _variables.scss is yours for client-specific shared variables (colors, breakpoints, etc.).

Everything else stays the same.


Preact / JSX

Preact is aliased to React automatically. JSX uses h and Fragment:

import { h, Fragment } from 'preact';

const MyComponent = () => (
  <div class="SCR001__wrapper">
    <p>Variant content</p>
  </div>
);

Target browsers

Builds target Chrome 80+, Firefox 78+, Safari 13+, Edge 80+. IE11 is not supported. Adjust the target array in scripts/build.js if needed.


Linting

ESLint runs on pull requests and pushes to main via GitHub Actions. The config (eslint.config.js) uses the @eslint/js recommended baseline plus an opinionated set of rules that catch common CRO mistakes — alert(), eval, debugger, var, loose equality, unused expressions, useless concatenation, etc. The CI command is:

npx eslint . --max-warnings 0

In VS Code, the workspace settings in .vscode/ prompt you to install the ESLint extension on first open. Once installed, lint runs inline as you type with the same rules CI enforces.


License

MIT — Arafat Islam

About

No description, website, or topics provided.

Resources

License

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors