Harper Integration Testing Framework Development and Impact Analysis

Ethan Arrowood, Head of Open Source Engineering @ Harper

About me

Experience

Education

Open source

Outline

  1. What am I testing? Harper, the platform under test
  2. The problem Harper's legacy integration suite
  3. The goal Every test file gets its own Harper
  4. How much parallelism? Measuring instead of guessing
  5. Networking contention Everything wants port 9925
  6. Framework agnostic One lifecycle, any test runner
  7. Surviving CI Sharding across runners
  8. Did it work? Harper core, harper-pro, and plugins
  9. Looking back

What am I testing?

Harper is an application platform

Harper can run all sorts of applications

Applications

  • Web apps: Vite, React, Astro, Next.js
  • APIs: REST, GraphQL, MQTT, MCP
  • Each app in its own isolated JavaScript context

AI

  • Models: Anthropic, Bedrock, OpenAI, Ollama
  • Built-in MCP server for LLM clients
  • GPU access for LLMs

Data

  • Storage: RocksDB (v5 default), LMDB, blobs
  • HNSW vector search with automatic embeddings
  • BM25 full-text search
  • Branched databases: a private data fork per app

Clusters

  • Multi-instance replication
  • Sharding by primary key

Testing is hard

"It works on my machine."

"That test's just flaky, re-run it."

"You can't run those locally."

"It only runs in CI."

"Don't touch it! It's finally green."

The problem

Harper's legacy integration suite

Before: one long, ordered test suite

// integrationTests/apiTests/tests/testSuite.mjs
describe('Main Test Suite', async () => {
  await import('./0_envCleanup.mjs');
  await import('./1_environmentSetup.mjs');
  await import('./2_dataLoad.mjs');
  await import('./3_sqlTests.mjs');
  // … 23 more, in exactly this order
  await import('./26_crlVerification.mjs');
});

Inherited from Harper v4, before the open-source split. Ref: HarperFast/harper@fb098c0

~1000 tests
  • Each test executed sequentially
  • Each file depends on the state of the previous
  • All tests shared a singular Harper instance

Shared state meant

One test fails? Must rerun the entire suite to debug.

7.5 min median to run the suite

p90 7.7 min · (May to Jun 2026)

Data and method

Most of each run was waiting

Median test step: 7.5 min

A lower bound: polling helpers aren't counted. On Jun 2, harper PR #1091 replaced the restart sleeps with polls that wait until Harper responds, and the median step fell to 5.6 min.

Data and method

9.3%

of main pushes had the suite fail on at least one Node version

Dec 2025 to Jun 2026

Data and method

61%

of failing runs had another Node version pass on the same commit

77 of 126 failing attempts, Dec 2025 to Jun 2026

Data and method

Fixing one bug, the old way

  1. Make a change
  2. Push it, then wait about 8 minutes for CI
  3. A test in a file you never touched fails
  4. Your change? State from an earlier file? Just flaky?
  5. Rerun the whole suite and hope

Every failure was a wild goose chase.

The goal

Every test file gets its own Harper

Define "integration"

Unit

A single unit of code; everything else mocked/stubbed.

too little

Integration

The complete application. No hooking into internals.

just right

End-to-end

The full production stack. Like end users experience.

too much
Goldilocks and The Three Bears illustration
Illustration by Arthur Rackham, 1918, in English Fairy Tales by Flora Annie Steel for “The Story of the Three Bears”en.wikipedia.org/wiki/Goldilocks_and_the_Three_Bears

Application is a black box

Inputs you control
  • Requests & operations
  • Config, env, CLI flags
  • stdin & signals
Application internals off-limits
Outputs you observe
  • Responses & status
  • Logs, stdout/stderr
  • Files & exit codes

No mocking, no stubbing, no reaching inside. Assert on behavior, never on implementation.

Application = Process

Integration testing an application means managing the lifecycle of the process

Manage the lifecycle

Interact with the process

What does one test need to be independent?

How do we…

isolate each test file in its own process?without changing Harper core

make the suite run everywhere?any OS, any test runner, locally and in CI

test every kind of application?

do it all performantly?

Early decision: use node:test test runner API

When running multiple complex processes

how much parallelism is actually possible?

Measuring instead of guessing

node:test "concurrency" is overloaded

Test runner concurrency = file-level process parallelism

node --test \
  --test-concurrency=5 \
  "*.test.js"

Test suite concurrency = in-process async concurrency

suite('x', { concurrency: true }, () => {
  test('a', async () => { … });
  test('b', async () => { … });
});

Node's default concurrency is built for cheap tests

node --test --test-concurrency
os.availableParallelism() - 1 // 11 on my laptop

Each test process starts an application process, so every test file needs two processes, not one.

However, computers are really good at doing multiple things at once.

The experiment

Work per file randomized within a mode:

https://github.com/ethan-arrowood/node-test-runner-parallelization-analysis

The gain plateaus, then reverses

Total median suite duration (shorter is faster)

Concurrency Median duration (ms)
1 9,939 ms 2 5,776 3 4,582 4 3,904 5 3,759 6 3,597 7 3,468 ← best 8 3,671 9 3,601 10 3,534 11 3,724 12 3,952

Past the plateau, variance more than doubles

20 test files · 25 samples per level · duration in ms · std dev: how much run times spread

ConcurrencyMedianStd devp95Worst run
73,4682943,9904,520
103,5346545,3395,466
113,7246905,0965,717

Std dev jumps from 294 at the optimum to 654 and 690 just past it
The run-to-run spread more than doubles.

The real developer experience is in the tail

20 test files · 25 samples per level · duration in ms · p95: 95% of runs finish faster

ConcurrencyMedianStd devp95Worst run
43,9043684,6485,238
103,5346545,3395,466

At 10, the median is 9.5% faster than at 4, but the p95 is 14.9% slower (5,339 vs. 4,648).
2.5× the concurrency for a slower tail.

Calculated default

When each test file starts its own application process, a good default is
half the available parallelism, plus one

concurrency = floor(availableParallelism / 2) + 1
            = floor(12 / 2) + 1
            = 7   // the measured optimum

This might not be perfect, but it's a better starting point than max parallelism, and remains configurable

Networking contention

Everything wants port 9925

Dynamic ports would mean core changes

The easy fix: let the OS hand out a free port.

server.listen(0, () => {
  const { port } = server.address();   // the OS picked a free one
});

But two problems:

How can we solve this without modifying core?

Not a real fix: get-port

// what get-port does, roughly:
async function getPort() {
  const server = net.createServer();
  await listen(server, 0);          // OS assigns a free port
  const { port } = server.address();
  await close(server);              // ← the port is released here
  return port;
}

const port = await getPort();
const harper = await createHarper(port);   // ← nothing reserved it in between

Between the release and the reuse, the OS can hand that port to any other process, especially with many test processes racing at once.

If we can't reliably reserve the ports…
what about the address?

Introducing loopback addresses

All of 127.0.0.0/8 (127.0.0.1 through 127.255.255.255) loops back to this machine: no network card, nothing leaves the computer

// every instance keeps its default ports
test/application 1 → http://127.0.0.2:9925
test/application 2 → http://127.0.0.3:9925
test/application 3 → http://127.0.0.4:9925

One caveat: macOS needs setup

Platform127.0.0.1127.0.0.2-127.255.255.255
Linux (Ubuntu)Enabled by defaultEnabled by default
macOSEnabled by defaultMust be added per address
WindowsEnabled by defaultEnabled by default

macOS: register each address

# once per boot, or install the provided launchd daemon
npx harper-integration-test-setup-loopback

# what it runs for each address in the pool
sudo ifconfig lo0 alias 127.0.0.2 netmask 255.255.255.255 up

Three options for port conflicts

OptionCost
Dynamic portsChanges to Harper core
get-portA race between checking a port and using it
An address per instanceOne-time setup on macOS

Every alias claims the same network

No explicit netmask? macOS assigns /8.

# address     claimed network
127.0.0.2  →  127.0.0.0/8
127.0.0.3  →  127.0.0.0/8
127.0.0.4  →  127.0.0.0/8

/32 removes the overlap

254 aliases. One macOS box. One paired measurement.

NetmaskmDNSResponder CPUUDP 5353 packets / 8 s
/8~65%85
/32~0%5

Found and fixed by Kris Zyp · integration-testing #21 · July 2026

How do we manage which process gets which address?

Loopback Address Pool: JSON file with an array of PIDs, shared by every test process.

[42, 43, null, 45, null, null]

index 0 → 127.0.0.2 (PID 42) · index 1 → 127.0.0.3 (PID 43) · …

Coordinating separate processes

OptionCost
A coordinator daemonAnother process to start and keep alive
Shared memory (Atomics)Only works between threads in one process
A file lockHas to handle holders that crash

Cross-process mutex to avoid conflicts

// 'wx' fails atomically if the file already exists
const handle = await open(LOCK_PATH, 'wx');
await handle.writeFile(token);   // we hold the lock
await handle.close();

File lock was simplest. Plus a stale-lock timeout for crashed processes.

A slow writer can lose the lock

await writeFile(pendingPath, JSON.stringify(pool));
await assertLockHeld(token);          // still ours?
await rename(pendingPath, POOL_PATH);  // atomic publish

If a write outlasts the stale timeout, another process takes the lock. The token check narrows the window where the old writer could overwrite the new claim.

Kris Zyp · integration-testing #32 and #33 · October 2026

Crashed tests would leak addresses forever

// signal 0 checks liveness without signalling
try {
  process.kill(pid, 0);       // alive: leave it allocated
} catch {
  loopbackPool[index] = null;  // dead: reclaim it
}

A Ctrl+C mid-run doesn't break the pool

Crashed runners left Harper running

Kris Zyp · integration-testing #30 · September 2026

Allocated is not the same as usable

// bind to port 0 just to prove the address exists
server.listen(0, loopbackAddress, () => {
  server.close(() => resolve(loopbackAddress));
});

1. Verify the whole pool at startup (ensure setup script was run)

2. Verify again per allocation to avoid collisions

A dying node can still hold the address

Harper's HTTP workers bind with SO_REUSEPORT, which lets several sockets share one port. A new node could share 9926 with an old node's lingering workers and split its connections.

// an exclusive bind fails with EADDRINUSE, even beside an SO_REUSEPORT socket
for (const port of [9925, 9926]) {
  if (await isPortInUseOnAddress(port, address)) skip(address);
}

Kris Zyp · integration-testing #17 and #20 · June 2026

Some listeners cover every address

A process on 0.0.0.0:9926, like a local Harper, overlaps the whole pool. On macOS and Windows it quietly takes a node's connections.

// an exclusive bind succeeds beside it there, so connect instead
const port = await findAcceptingPort(address, [9925, 9926]);
if (port) throw new ForeignListenerError(address, port);

Dawson Toth · integration-testing #40 · October 2026

The API is shaping up

// ctx is a general options object

await startHarper(ctx);
// 1. create a temporary install directory
// 2. allocate a loopback address from the pool
// 3. spawn Harper, wait until it is actually ready
// 4. populate ctx.harper

Framework agnostic

The API imports nothing from a test runner

startHarper(ctx, options?)
setupHarperWithFixture(ctx, fixturePath, options?)
killHarper(ctx)
teardownHarper(ctx)
createHarperContext(name?)   // for non-node:test frameworks

ctx is any object with an optional name and a harper property to write to.

Deliberately loose, so a node:test context and a plain object both satisfy it.

Keep it flexible

# the convenience runner sets a smarter concurrency default
npx harper-integration-test-run "integrationTests/**/*.test.ts"

# plain node:test runs the same files
node --test "integrationTests/**/*.test.ts"

https://github.com/HarperFast/integration-testing/blob/main/src/run.ts

The convenience runner (configures a smarter concurrency default) holds no state.

Every test file still runs under plain node --test, and parallelization still works.

Two runners, same lifecycle

node:test - file per process

import { suite, test, before, after } from 'node:test';

suite('install', (ctx: ContextWithHarper) => {
  before(async () => {
    await startHarper(ctx);
  });
  after(async () => {
    await teardownHarper(ctx);
  });
  test('serves', async () => {
    const res = await fetch(ctx.harper.httpURL);
    strictEqual(res.status, 200);
  });
});

Real example: HarperFast/harper integrationTests/apiTests/headers.test.mjs

Two runners, same lifecycle

Playwright - worker-scoped fixture

import { test as base, expect } from '@playwright/test';

const test = base.extend<{}, { harper: HarperContext }>({
  harper: [makeHarperFixture(name), { scope: 'worker' }],
});

test('home page renders', async ({ page, harper }) => {
  await page.goto(harper.httpURL);
  await expect(page.locator('h1')).toHaveText('Next.js v16');
});

Real example: fixture.ts and next-16.pw.ts in HarperFast/nextjs

Surviving CI

How do you think an M3 MacBook compares to a default GitHub CI runner?

One default runner can't run all the tests

Parallelize across runners: a matrix that shards the suite, splitting the test files into groups that run on separate machines.

# .github/workflows/integration-tests.yml
strategy:
  matrix:
    shard: [1, 2, 3, 4]
steps:
  - run: node --test --test-shard=${{ matrix.shard }}/4

--test-shard=1/4 always runs the same group of tests, on any machine
https://github.com/HarperFast/harper/blob/main/.github/workflows/integration-tests.yml

Did it work?

Twelve months of Harper CI data

Who it was for

Initially

  • Harper engineers changing core
  • The harper-pro team testing clusters
  • Plugin and template authors, like Next.js
  • Anyone running one test on a laptop

Later

  • Test Harper applications (internal and external)
  • Provide confidence for customer migration and SaaS upgrades

Porting it took 3.5 weeks

Kris Zyp, working with coding agents

May 15 to Jun 9, 2026

  • 14 PRs, one legacy file or group each
  • 17 self-contained files, each with its own Harper
  • 19,977 lines of legacy suite and infrastructure deleted

Data and method

The legacy suite stayed flat. The new one grew.

Legacy suiteFramework tests

Harper engineers added 1,681 tests after the port finished.

Data and method

More PRs come with integration tests

Merged harper pull requests (PRs) by month, and how many touched integration tests

Touched integration testsOther merged PRs

Bar height: PRs merged that month. Above each bar: PRs that touched integration tests, of all merged. On the bar: that share.

Data and method

More code changes ship with a test

PRs that change non-test code, and how many also change an integration test

Also changed an integration testCode only

Bar height: merged PRs that changed non-test code. Above each bar: how many also changed an integration test. On the bar: that share.

Data and method

Today, one CI run executes

3,237 tests

starting 539 Harper instances, in 7.0 minutes

six Linux Node.js 24 shards running at once, Oct 31, 2026

Data and method

95 minutes of tests, finished in 7

Summed suite time95.2 minall six shards, added up
Wall time7.0 minthe slowest shard

About 13.7× less. The sum was measured while files ran concurrently, so it estimates a serial run rather than measuring one.

Data and method

On every platform we test in CI

30 jobs

on every push to main: Node 24 and 26, Windows, Bun, and uWS

six shards each · nightly and release branches add Node 22, for 36

Data and method

And returns a result in about 12 minutes

12.3 min

median time to a full-matrix result

267 successful first-attempt main runs, Oct 2 to 31

Data and method

And CI runs them all the time

Runs of harper's integration test workflow, by month

Through MayFrom June (the port finished Jun 9)

Data and method

CI is not always green

Main pushes with a failed job, by month. Failure rate, not flake rate.

Data and method

CI isn't reliable yet

Bun is an alternate JavaScript runtime and uWS an alternate HTTP server. We run both in CI but treat them as experiments. On Linux Node versions alone, 10% of main runs failed, against 9.3% for the legacy suite with under a third of the tests.

Data and method

harper-pro

Cluster tests on one machine

Cluster tests used to need a cloud VM

  1. Provision a VM for every run
  2. Start the nodes in containers; test over SSH
  3. Copy the logs back; destroy the VM

Data and method

Now a cluster fits in one test file

await Promise.all([
  startHarper(ctxA, config(hostnameA)),
  startHarper(ctxB, config(hostnameB)),
]);
// two nodes, two loopback addresses, one runner

harper-pro integration tests

346 test cases

across 137 test files

Oct 31, 2026

Source: harper-pro integrationTests, Oct 31

Harper plugins

Starting with Next.js

Every Next.js version gets its own Harper

10 fixtures

Next.js 14, 15, and 16

webpack, Turbopack, ISR, use cache, cluster

Source: HarperFast/nextjs integrationTests

Harper tests itself against the Next.js plugin

4,073

check runs on Harper PRs since Jul 15, 2026

Each run builds the PR's Harper, then runs the Next.js plugin's own test suite against it

Workflow · Data and method

Plugin breakage shows up in Harper's PR

12 of 257

sampled check runs flagged a failure before merge

Advisory. Some flags may come from the plugin's own main branch, not the Harper change.

Data and method

Adopted across HarperFast

34 repos

import @harperfast/integration-testing

Largest public suites: harper-pro, oauth, agent-memory

Data and method

Looking back

Discovering core testing values for high quality tests

What I'd do differently

Measure the before, not just the after.

What it changed

Next: making CI reliable

Ethan Arrowood climbing a steep snow couloir with skis strapped to a pack and an ice axe in hand

Thank you

Ethan Arrowood · Head of Open Source Engineering, Harper · ethanarrowood.com