Skip to content

Declaring a Local Emulator

A lexicon that can be exercised without a cloud account declares an emulator capability: enough for chant to boot the container, wait for it to be healthy, and tell tooling where it is. chant emulator up|down|status and behold serve --local both drive it.

src/op/activities/my-emulator.ts
import { emulatorLifecycle, type EmulatorCapability, type EmulatorSpec } from "@intentius/chant/op";
export const MY_EMULATOR_SPEC: EmulatorSpec = {
name: "chant-myfake", // default container name
image: "org/myfake:1.2.0", // pinned, not :latest
containerPort: 4599,
healthPath: "/_myfake/health",
upstream: { repo: "org/myfake" }, // where releases are published
};
export const MY_EMULATOR: EmulatorCapability = {
spec: MY_EMULATOR_SPEC,
env: (endpoint) => ({ MYFAKE_ENDPOINT_URL: endpoint }),
};
const myfake = emulatorLifecycle(MY_EMULATOR_SPEC);
export const myfakeUp = (args = {}, signal?: AbortSignal) => myfake.up(args, signal);
export const myfakeDown = (args = {}, signal?: AbortSignal) => myfake.down(args, signal);
// src/plugin.ts
emulator: MY_EMULATOR,

EmulatorSpec (packages/core/src/op/emulator-lifecycle.ts:12) has four required fields and three optional ones:

FieldRequiredMeaning
nameyesDefault container name
imageyesDocker image, with a version tag
containerPortyesPort the emulator listens on inside the container
healthPathyesHealth path on the host port
readyno(healthBody: string) => boolean. Default: any 200 is ready
runArgsnoExtra docker run args inserted before the image, e.g. a socket mount
upstreamno{ repo }, the owner/repo whose latest release names the current version

lexicons/fly/src/op/activities/flaps.ts:25 is the reference declaration.

The same spec drives emulatorLifecycle(), so the wrapper your Ops already use and the capability chant boots are one object rather than two that can drift.

This distinction once cost the repo three of its four emulators. azure and gcp both built the exact spec above in src/op/activities/ and used it from their Ops without setting plugin.emulator, so chant emulator up --all booted Floci and nothing else while the local-testing docs presented all three clouds as equal local targets (chant #1345). All five capabilities are declared now. The lesson holds anyway. The wrapper is not the declaration.

emulator is typed EmulatorDeclaration, which is one capability or a readonly array of them. fly declares two (lexicons/fly/src/plugin.ts:33):

emulator: [MUDFLAPS_EMULATOR, SPRITZER_EMULATOR],

chant emulator reports one line per emulator rather than per lexicon, and --lexicon fly acts on both.

env(endpoint) returns what points tooling at the running emulator. Two things depend on getting it right:

  • chant emulator up --json hands it to a consumer verbatim, so credentials and a region belong here too. Floci returns AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY and AWS_REGION alongside AWS_ENDPOINT_URL (lexicons/aws/src/op/activities/floci.ts:70).
  • --live --env <name> endpoint injection picks out only the variables whose value is the endpoint and sets those, leaving the credentials alone. Core derives that by calling env() with a sentinel and keeping the keys that carry it (endpointEnvVars in packages/core/src/op/emulator-lifecycle.ts:82). That replaced a hand-maintained map which listed two of the four lexicons with an endpoint variable and asserted azure had none, while azure’s own describe-resources.ts read AZURE_ENDPOINT_URL on every call.

Each of the five declared capabilities names exactly one endpoint variable: AWS_ENDPOINT_URL, AZURE_ENDPOINT_URL, GCP_ENDPOINT_URL, FLY_FLAPS_BASE_URL (mudflaps) and SPRITES_BASE_URL (spritzer). Return {} if your emulator is reached only through an explicit apply argument. That is a real answer, and a better one than naming a variable nothing reads.

image should carry a version tag. An emulator on :latest cannot be behind, which sounds fine until a local suite that passed yesterday fails today and nothing in the repo records what moved.

Declaring upstream.repo puts the pin in the weekly freshness check (scripts/check-emulator-freshness.ts), which compares each pin against the latest GitHub release and opens a single advisory notice. It never bumps anything: per the chant #808 policy a pin moves when a consuming test needs the newer emulator, not because a release happened.