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.
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
definefor build-time constants.shared.jsnever 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.
git clone https://github.com/maaislam/abtestrig
cd abtestrig
npm installThat'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.)
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.
macOS / Linux — open a terminal and run:
cd <your-abtestrig-path> # e.g. /Users/alice/dev/abtestrig
npm linkBoth abtestrig and cro are now global commands. Done.
Windows — npm link creates a symbolic link under your global node_modules folder, and Windows blocks creating symlinks by default. You have two ways around this:
-
Press Windows key, open Settings.
-
Go to Privacy & security → For developers.
-
Turn Developer Mode ON.
-
Open a fresh PowerShell window, then:
cd <your-abtestrig-path> # e.g. C:\Users\Alice\dev\abtestrig npm link
-
Press the Windows key, type
powershell. -
Right-click Windows PowerShell in the search results → Run as administrator.
-
Click Yes on the UAC permission prompt.
-
In the elevated window (note: it opens in
C:\Windows\System32by default — you mustcdinto your project folder):cd <your-abtestrig-path> # e.g. C:\Users\Alice\dev\abtestrig npm link
-
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 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.
-
Open a regular PowerShell window (admin not needed).
-
Run
notepad $PROFILEto open your profile file. If Notepad asks "Do you want to create a new file?", click Yes. -
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
-
Save (Ctrl+S) and close Notepad.
-
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 RemoteSignedThen close and reopen PowerShell again.
-
Open Git Bash (or any bash terminal).
-
Edit your bashrc —
nano ~/.bashrcif you're comfortable with nano, orcode ~/.bashrcif you have VS Code on your PATH. Either creates the file if it doesn't exist. -
Add this line, replacing
<your-abtestrig-path>with the path you copied frompwd: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\abtestrigbecomes/c/Users/Alice/dev/abtestrig(lowercase drive letter, forward slashes). Thepwdcommand above already gives it to you in this format. -
Save and close.
-
Run
source ~/.bashrcto load it in the current session, or just close and reopen the terminal. Test withabtestrig --help.
abtestrig createYou'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.
abtestrig watch --cn=Screwfix --fn=SCR001This 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.
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 testingOne-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.
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-testWhen 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.
Starts the dev server and WebSocket server without watching. Useful for QA on pre-built files.
abtestrig serve --port=3000Run several experiments side-by-side on the same page. See Interaction testing with stacks below.
abtestrig stack stacks/<name>.json --port=3000Running 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.
{
"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": trueopts that entry into live reload. Default is frozen (built once, no watcher). Typically the experiment in active development getswatch: true; reference experiments stay frozen.
abtestrig stack stacks/homepage-conflict-test.jsonEach 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
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:
- Multi-select prompt of other experiments in
clients/to include. - Per-experiment variation prompt.
- Where to place the newly-scaffolded experiment (FIRST or LAST in the order).
- 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.
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/
...
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.
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.
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.
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.
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.
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.
- Copy
templates_custom/exampletotemplates_custom/<clientname> - Adjust the tracking setup, activation conditions, and any client-specific patterns
- Add the client name to the
CLIENT_TEMPLATESmap inscripts/create.js
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 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>
);Builds target Chrome 80+, Firefox 78+, Safari 13+, Edge 80+. IE11 is not supported. Adjust the target array in scripts/build.js if needed.
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 0In 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.
MIT — Arafat Islam