A minimal TypeScript test runner inspired by tester‑army/e2e
Welcome to another official UVF IT guide! In the next 45 minutes you’ll assemble a tiny end‑to‑end test runner that lets you write tests like the real e2e framework, using plain TypeScript and a local SQLite store for recorded actions.
We’ll keep it free‑tier, no external AI APIs – our “agent” will be a stub that simply logs the requested action. You’ll end up with a runnable npm test that opens a page with Playwright and verifies a simple assertion.
Create a fresh npm package and add the minimal dependencies. This gives us a clean TypeScript environment and pulls in Playwright for browser automation as well as SQLite for persisting agent steps.
We’ll also install ts-node so we can run TypeScript files directly without a separate compile step. All commands are run from the project root.
npm init -y
npm install typescript ts-node @types/node playwright sqlite3
npx tsc --init --rootDir src --outDir dist --esModuleInterop true --module commonjs --target es2022A tiny table stores each agent step so later runs can replay without AI calls. The table records the raw instruction and a timestamp, which is enough for our simple clone.
We expose three helper functions: initDb() to create the table, recordStep() to insert a new row, and getSteps() to fetch the log for debugging or replay. All functions return Promises for easy async/await usage.
src/db.ts
import sqlite3 from 'sqlite3';
import { open, Database } from 'sqlite';
let db: Database<sqlite3.Database, sqlite3.Statement>;
export async function initDb() {
db = await open({ filename: ':memory:', driver: sqlite3.Database });
await db.exec(`CREATE TABLE IF NOT EXISTS steps (
id INTEGER PRIMARY KEY AUTOINCREMENT,
instruction TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);`);
}
export async function recordStep(instruction: string) {
if (!db) throw new Error('DB not initialized');
await db.run('INSERT INTO steps (instruction) VALUES (?)', instruction);
}
export async function getSteps() {
if (!db) throw new Error('DB not initialized');
return db.all<{id:number;instruction:string;created_at:string}>('SELECT * FROM steps ORDER BY id');
}The agent simply records the natural‑language instruction; the test API mirrors the real e2e’s test function. This keeps the clone lightweight while still showing how steps flow through the system.
When a test calls runInstruction, the agent logs the action, stores it in SQLite, and resolves with the same string – mimicking a successful LLM response without any external calls.
src/agent.ts
import { recordStep } from './db';
/**
* Dummy agent that pretends to process an instruction.
* It logs the instruction, persists it, and returns it unchanged.
*/
export async function runInstruction(instruction: string): Promise<string> {
console.log('Agent received:', instruction);
await recordStep(instruction);
// In a real runner this would call an LLM; we just echo back.
return instruction;
}In this step we create the heart of our clone: a tiny test function that Playwright can use. It accepts a human‑readable name and an async callback that receives a Playwright Page fixture plus a stub “agent” we’ll use to log actions. All defined tests are stored in an array and run sequentially when the module finishes loading, mimicking the simple flow of tester‑army/e2e.
We also set up a very small Playwright launch routine that opens Chromium, creates a fresh page for each test, and closes the browser when everything is done. This keeps the implementation short while still giving us a real browser to interact with.
src/runner.ts
import { chromium, Browser, Page } from 'playwright';
type TestCallback = (page: Page, agent: { act: (msg: string) => void }) => Promise<void>;
interface TestDef { name: string; fn: TestCallback }
const tests: TestDef[] = [];
export const test = (name: string, fn: TestCallback) => {
tests.push({ name, fn });
};
// a very small stub agent that just logs what it "does"
const stubAgent = {
act: (msg: string) => console.log('🤖 act:', msg),
};
async function runAll() {
const browser: Browser = await chromium.launch({ headless: false });
for (const { name, fn } of tests) {
const context = await browser.newContext();
const page = await context.newPage();
try {
console.log(`▶️ Running test: ${name}`);
await fn(page, stubAgent);
console.log(`✅ PASS: ${name}`);
} catch (err) {
console.error(`❌ FAIL: ${name}`);
console.error(err);
} finally {
await context.close();
}
}
await browser.close();
}
if (require.main === module) {
runAll();
}Now we write a concrete test that uses the test helper we just exported. The test navigates to a real web page, grabs the title, and feeds it to our stub agent so you can see the logging in action. This mirrors the example from the original e2e project but stays tiny and easy to understand.
Because the runner automatically executes all collected tests, simply importing the file is enough to trigger the flow when we run it with ts-node.
tests/sample.e2e.ts
import { test } from '../src/runner';
test('example.com shows correct title', async (page, agent) => {
await page.goto('https://example.com');
const title = await page.title();
agent.act(title);
});With the runner and a sample test in place, we can finally execute everything. Using ts-node lets us run TypeScript directly without a build step, keeping the workflow lightweight. When the command finishes you should see Chromium open, the title logged by the stub agent, and a PASS line for the test.
If anything goes wrong, the console will show a stack trace and a ❌ FAIL marker, making debugging straightforward.
npx ts-node -r ts-node/register tests/sample.e2e.ts| Check | Command / Why it matters |
|---|---|
| npm install finishes without errors | npm i -D playwright ts-node typescript && npx playwright install |
| Playwright launches a browser window | Running the test opens Chromium (headless:false) |
| Console shows '🤖 act:' line and test passes | The ts-node output contains the stub log and a ✅ PASS summary |
You now have a tiny end‑to‑end test runner that mimics the e2e API. Extend it by persisting the SQLite file, adding real LLM calls, or packaging it as an npm CLI similar to npx e2e init. Happy testing!