Minimal End‑to‑End Test Runner Clone

Build a tiny e2e test runner in 45 min with Node.js and SQLite

Welcome to another official UVF IT guide! In this quick build you’ll create a stripped‑down version of the e2e testing framework, letting you write natural‑language‑driven tests for a simple web app without any paid APIs.

We’ll use plain Node.js, Express for a tiny demo app, and SQLite to store recorded actions – all free and beginner‑friendly.

Before you start…

Clone Architecture
LOCAL STACK HTTP store/replay Express Demo App Test Runner (Node) SQLite DB (actions)
1

Phase 1: Scaffold Project

Create a fresh npm workspace and install minimal dependencies. This gives us a clean folder, a package.json to track scripts, and the tiny libraries we need: Express for the demo server and better‑sqlite3 for fast, zero‑config SQLite access.

All commands are run from the terminal; they will produce a folder structure that subsequent phases will extend.

mkdir e2e-clone && cd e2e-clone
npm init -y
npm install express better-sqlite3
2

Phase 2: Core Feature – Data Model

Set up a tiny SQLite DB to record agent actions as JSON strings. The DB lives in a file called actions.db and contains a single table steps with an auto‑increment id and a JSON column payload. Helper functions let the runner insert a step and later retrieve the full history for replay.

Keeping the DB logic isolated in db.js makes the rest of the codebase simple and testable.

db.js

const Database = require('better-sqlite3');
const path = require('path');

// Ensure the DB file lives next to this module
const dbPath = path.resolve(__dirname, 'actions.db');
const db = new Database(dbPath);

// Initialise table if it doesn't exist
db.exec(`CREATE TABLE IF NOT EXISTS steps (
  id INTEGER PRIMARY KEY AUTOINCREMENT,
  payload TEXT NOT NULL
);`);

/** Insert a step (any serialisable object) */
function addStep(stepObj) {
  const stmt = db.prepare('INSERT INTO steps (payload) VALUES (?)');
  stmt.run(JSON.stringify(stepObj));
}

/** Retrieve all steps in insertion order */
function getAllSteps() {
  const rows = db.prepare('SELECT id, payload FROM steps ORDER BY id').all();
  return rows.map(r => ({ id: r.id, payload: JSON.parse(r.payload) }));
}

module.exports = { addStep, getAllSteps };
3

Phase 3: Core Feature – Test Runner API/UI

In this phase we create the heart of our clone: a tiny runner that can read a test file, simulate agent actions, and store each step in a SQLite database. On the first execution the runner records every action, and on later runs it reads the stored steps and replays them, letting you see the difference between record and replay modes. The code is deliberately simple so you can follow the flow and later replace the mock actions with real browser commands.

We’ll use the sqlite3 package for persistence and expose a single run(testPath) function. The runner loads the test file, gives it a mock agent object with click and type methods, and decides whether to record or replay based on whether a record already exists for that test. This gives you a clear API and a tiny UI in the console.

runner.js

const fs = require('fs');
const path = require('path');
const sqlite = require('sqlite3').verbose();
const db = new sqlite.Database('records.db');

db.run('CREATE TABLE IF NOT EXISTS steps(test TEXT, idx INTEGER, action TEXT, target TEXT, value TEXT)');

function getRecorded(test) {
  return new Promise((res, rej) => {
    db.all('SELECT * FROM steps WHERE test = ? ORDER BY idx', [test], (err, rows) => {
      if (err) return rej(err);
      res(rows);
    });
  });
}

function saveStep(test, idx, action, target, value) {
  db.run('INSERT INTO steps(test, idx, action, target, value) VALUES(?,?,?,?,?)', [test, idx, action, target, value]);
}

async function run(testFile) {
  const testName = path.basename(testFile);
  const recorded = await getRecorded(testName);
  const mode = recorded.length ? 'replay' : 'record';
  console.log(`Running ${testName} in ${mode.toUpperCase()} mode`);
  let stepIdx = 0;
  const agent = {
    click: (target) => {
      if (mode === 'record') saveStep(testName, stepIdx++, 'click', target, null);
      console.log(`${mode === 'record' ? 'Executing' : 'Replaying'} click on ${target}`);
    },
    type: (target, value) => {
      if (mode === 'record') saveStep(testName, stepIdx++, 'type', target, value);
      console.log(`${mode === 'record' ? 'Executing' : 'Replaying'} type '${value}' into ${target}`);
    }
  };
  if (mode === 'replay') {
    for (const row of recorded) {
      console.log(`Replaying ${row.action} on ${row.target}` + (row.value ? ` with '${row.value}'` : ''));
    }
    return;
  }
  const testModule = require(path.resolve(testFile));
  await testModule(agent);
}

module.exports = {run};
4

Phase 4: Demo App & Test File

Now we spin up a minimal Express server that the runner can interact with. The app only needs a single /status endpoint that returns a short string – this keeps the focus on the runner rather than the application under test. We also create a sample test file that uses the runner’s DSL (agent.click and agent.type) to hit the endpoint and simulate a user flow.

The test file lives alongside the runner and will be passed to runner.run. Because the runner’s agent is a mock, the test simply demonstrates the recording and replay behavior without real HTTP calls, making it safe to run anywhere. Feel free to replace the mock actions with real fetch calls later on.

app.js

const express = require('express');
const app = express();
const PORT = 3000;

app.get('/status', (req, res) => {
  res.send('Pro');
});

app.listen(PORT, () => {
  console.log(`Demo app listening on http://localhost:${PORT}`);
});
5

Phase 5: Run Locally & Verify

With the server and runner in place, it’s time to see the clone in action. First start the Express app in the background, then invoke the runner twice on the same test file. The first run should record each step, printing “Executing” messages, while the second run will detect the stored steps and print “Replaying” messages instead.

Observe the console output and optionally inspect the records.db SQLite file to confirm that steps were persisted. This completes the minimal end‑to‑end test runner – you now have a working record‑and‑replay loop built from scratch.

# Install dependencies (run once)
npm install express sqlite3

# Start the demo app in the background	node app.js &
APP_PID=$!

# Give the server a moment to start
sleep 1

# First run – records actions
node -e "require('./runner').run('./test1.js')"

# Second run – replays recorded actions
node -e "require('./runner').run('./test1.js')"

# Clean up
kill $APP_PID

Verifying the Stack

CheckCommand / Why it matters
npm init and dependencies installednpm list express sqlite3 shows versions
Demo app responds on http://localhost:3000/statuscurl http://localhost:3000/status returns 'Pro'
First test run records steps (no replay output)node -e "require('./runner').run('./test1.js')" shows 'Executing' lines
Second test run replays recorded stepsnode -e "require('./runner').run('./test1.js')" shows 'Replaying' lines

You did it!

You now have a minimal e2e‑style test runner that records actions to SQLite and replays them. Extend by adding real browser automation (e.g., Playwright) or plug in an LLM for natural‑language agent steps.