Skip to content

Architecture

Argus is a hexagonal design: a pure domain, ports around it, adapters at the edges, and a CLI as the composition root.

Host process (Node)
args
→ resolve framework paths
→ resolve Hermes binary (provisioning chain)
→ discover test files (globs)
→ for each file, bounded by concurrency:
bundle with esbuild
→ write sealed bundle to a temp file
→ spawn standalone hermes
→ parse the framed result line
→ remap stacks with source maps
→ aggregate results (discovery order)
→ render CLI report
→ exit with the worst-case code
Hermes subprocess
sealed IIFE bundle:
polyfills
+ Argus framework
+ user test file
+ virtual entry calling run(<private nonce>)
Output channel
stdout carries user logs plus exactly one framed result line:
__ARGUS_RESULT__:<nonce>:<json>

Hermes cannot ask the host for modules at run time. There is no require, no resolver, no dynamic import reaching back into your project. Everything must be bundled before the VM starts.

Three consequences, all of them good:

  • Deterministic. What runs is a single artifact; there is no resolution to vary between machines.
  • Isolated per file. No shared realm between files, so no cross-file leakage and no ordering coupling.
  • Trivially parallel. Parallelism is just process parallelism, bounded by --concurrency.

And one that is a real limitation: module mocking cannot work by intercepting a registry, because there is no registry. See Mocks & spies.

The bundler synthesizes an entry module rather than pointing esbuild at your test:

import "<polyfill>";
import { run } from "<framework>";
import "<your test file>";
run("<nonce>");

Polyfills first (Hermes has only print — no console, no global), then the framework which installs the globals, then your test which registers suites, then run.

The nonce is inlined as a private argument in this module’s scope. Your test modules are separate module scopes and cannot read it. It is deliberately not injected as a global define, which would expose it to user code and make the result frame forgeable.

The domain in @arguslab/core is pure — no filesystem, no process, no adapter imports, not even node:path. On-disk layout is expressed as path segments that callers join.

Every package named below is internal: only @arguslab/argus is published, and the host side of it is bundled into one file at build time. The seam is a source-tree boundary, not an installable one — see Package map.

Three ports, five adapters — HermesProvisioner has three implementations, which is the whole reason it is a port:

Port Adapter Responsibility
Bundler EsbuildBundler Virtual entry → sealed IIFE, syntax lowering, source map
Engine HermesSpawnEngine Spawn hermes on a temp file, capture stdout
HermesProvisioner LocalPathAdapter Use a binary that is already on disk
HermesProvisioner PrebuiltAdapter Download and verify one Argus publishes
HermesProvisioner SourceBuildAdapter Build one from the pinned Hermes tag

Those three provisioners are tried in a fixed order, alongside sources that need no adapter at all: an explicit path, a binary vendored in the project, the local cache, the legacy VM React Native ships in node_modules for 0.73–0.82, a prebuilt, and finally a source build. Six sources, and the run summary names which one answered.

Terminal rendering and exit-code policy live in @arguslab/reporter-cli, which the CLI calls directly as functions. It has no port: a port earns its place when something might be swapped behind it, and there has only ever been one way to render to a terminal. A second reporter — JSON, JUnit — is the change that would justify introducing one.

This is why the provisioning chain could be developed against a local binary long before any prebuilt existed: the prebuilt adapter is a swap-in behind the port.

The Hermes adapter writes the bundle to a temp file and passes a path. It never pipes source through stdin.

Hermes reads stdin as a REPL. Feeding a bundle in that way silently changes evaluation semantics. File mode only, always.

mapPool(files, concurrency, runFile) keeps at most concurrency files in flight. Results are then aggregated in discovery order, not completion order, so the report is stable run to run even though execution is not.

Two places in this codebase carry more invariants than their size suggests:

  • packages/framework/src/index.ts — the result channel. Captured primordials, the framed line, the hand-written serializer. Treat as a high-integrity boundary; see The result protocol.
  • packages/adapter-sourcemap — stack remapping, which must be total. A throw during reporting would demote a real test failure into an infrastructure failure. See Source maps.