Local Testing — The Test Harness
Show the whole prompt
Read https://intentius.io/chant/local-testing/testing-harness/.
Write a vitest suite for this project with `deployStack` from `@intentius/chant/testing`: deploy in `beforeAll`, assert with `assertLive`, and `destroy()` in `afterAll`.
Set `ownership.stack` and a `{ name: "test-*", endpoint: "http://localhost:4566" }` environment in `chant.config.ts`, so the suite runs against a local emulator.
Boot the emulator with `npx chant emulator up`, run the suite, and show me the output. Then open a pull request with the suite and the config.
Never deploy to a real cloud account or shared environment (with `chant run`, `chant components` or a platform's own CLI), approve (a pull request review, `chant approve` or `chant run approve`), override a policy denial (`chant approve policy-override`), or merge; never cut or publish a release (`chant release cut --yes` or `chant release publish`), push or delete a `ci/*` tag (`chant ci tick` pushes them), or change the value of an existing flag; never touch `.chant/allowed_signers` or `chant/lifecycle`.Optional: hand this page to your coding agent
Read https://intentius.io/chant/local-testing/testing-harness/.Write a vitest suite for this project with `deployStack` from `@intentius/chant/testing`: deploy in `beforeAll`, assert with `assertLive`, and `destroy()` in `afterAll`.Set `ownership.stack` and a `{ name: "test-*", endpoint: "http://localhost:4566" }` environment in `chant.config.ts`, so the suite runs against a local emulator.Boot the emulator with `npx chant emulator up`, run the suite, and show me the output. Then open a pull request with the suite and the config.Never deploy to a real cloud account or shared environment (with `chant run`, `chant components` or a platform's own CLI), approve (a pull request review, `chant approve` or `chant run approve`), override a policy denial (`chant approve policy-override`), or merge; never cut or publish a release (`chant release cut --yes` or `chant release publish`), push or delete a `ci/*` tag (`chant ci tick` pushes them), or change the value of an existing flag; never touch `.chant/allowed_signers` or `chant/lifecycle`.@intentius/chant/testing turns “deploy my stack, assert against it, destroy it” into a vitest suite. One call deploys a real instance of your project into a per-run environment; the handle it returns carries the built outputs, the discovered entities, the environment name, and a destroy() that sweeps exactly what the deploy created. Locally the suite runs against an emulator for $0; in CI the identical suite can target a real account.
import { beforeAll, afterAll, test, expect } from "vitest";import { deployStack, type DeployedStack } from "@intentius/chant/testing";
let stack: DeployedStack;
beforeAll(async () => { stack = await deployStack({ dir: "src", suite: "my-service" });});
afterAll(async () => { if (stack) await stack.destroy();});
test("the queue is declared and deployed", () => { expect(stack.entities.has("taskQueue")).toBe(true);});One deploy per suite, not per test: beforeAll/afterAll, with assertions in between running against the same live instance.
The API
Section titled “The API”deployStack(options) takes:
| Option | Meaning |
|---|---|
dir | The chant project directory (where the infra source lives). Required. |
suite | Suite name folded into the derived environment name. Defaults to the directory’s basename. |
env | Explicit environment name, overriding the derivation — for a shared long-lived test environment. Must be legal for the project’s declared environments, exactly as --env would be. |
params | Build parameters, exactly as --param name=value flags would supply them. |
It builds the project the way chant build would (build parameters, the ownership marker, build roots), then applies each built output additively through the local Op executor — the same nativeApply path chant run uses, with deleteMode: "never". A build error, an unresolved parameter, or a failed apply rejects the promise; nothing is returned half-deployed.
The resolved handle is a DeployedStack:
outputs— the built outputs, keyed by lexicon. What was deployed, available for template-level assertions.entities— the discovered entities, keyed by name.env— the environment this deploy targeted. The teardown key.assertLive(name, options?), observation-backed assertions against the live stack. Covered below.destroy()— the marker-scoped sweep of that environment, in-process. The same operation aschant lifecycle teardown <env> --yes.
destroy() throws a TeardownIncompleteError when any candidate failed to delete or the plan had holes (something chant may own could not be read). An environment that cannot be called clean is a test failure, never a silent leak. It is safe to call again: teardown is stateless, and a second call over a clean environment plans nothing.
Asserting the deploy is live
Section titled “Asserting the deploy is live”entities.has("taskQueue") and the outputs map both check what was declared and built, not what is actually running. assertLive is the other half, calling describeResources() against exactly one named entity and reporting back with either a resolved value or a thrown error. Reach for it whenever a test needs to know the live stack, not just the plan that was sent to it.
assertLive(name: string, options?: { status?: string }): Promise<ResourceMetadata>The call takes two forms.
// exists, marker-verifiedconst bucket = await stack.assertLive("dataBucket");
// exists, marker-verified, and reports this statusconst queue = await stack.assertLive("taskQueue", { status: "CREATE_COMPLETE" });On success it resolves to the entity’s ResourceMetadata. On failure it throws rather than returning a falsy result, for three distinct reasons.
- The entity is observed absent.
describeResourceslooked, and the environment reported no such resource, soassertLivethrowsLiveAssertionError. - The entity is not observed at all, an unmapped kind, a failed call, missing credentials. That counts as neither a pass nor an absence, the same tri-state the observation contract draws for
lifecycle plan, soassertLivethrowsUnobservedAssertionError, a distinct type so a suite can catch could-not-tell apart from a confirmed miss. - A same-named resource is observed, but it carries another deploy’s
{ stack, env }marker, or no marker at all where the lexicon’s ownership channel says one should be present, soassertLivethrowsLiveAssertionErrornaming what it found.
Both error types are exported from @intentius/chant/testing:
import { LiveAssertionError, UnobservedAssertionError } from "@intentius/chant/testing";Endpoint resolution is identical to deployStack and destroy(): ambient endpoint variables win, then the target environment’s declared endpoint, then the real cloud. See Emulator or real cloud below.
assertLive checks existence and status only. Property-level assertions, comparing a live resource’s actual configuration against what was declared, wait on deep observation (chant #1014); status is as deep as v1 goes.
What the project must declare
Section titled “What the project must declare”Two config requirements, both about identity:
export default { lexicons: ["aws"], ownership: { stack: "my-service" }, environments: ["dev", "prod", { name: "test-*", endpoint: "http://localhost:4566" }],} satisfies ChantConfig;ownership.stack is required. The harness stamps every deployed resource with the marker { stack, env }, and destroy() selects on that marker and nothing else — a deploy that stamps nothing is a deploy nothing can sweep, so the harness refuses it up front.
The "test-*" pattern entry legalizes the environment names the harness derives. A project that declares environments rejects unknown names, exactly as --env does; the pattern entry makes every test-<anything> name legal without listing each one. Without it, deployStack fails with the hint to add it.
Isolation naming
Section titled “Isolation naming”The default environment for one run is test-<suite>-<nonce> — the suite name slugged, plus a six-character run nonce. Parallel CI jobs of the same suite get different environments, so they never collide. The environment name is also the deploy boundary’s name where the target has one (the CloudFormation stack name on AWS), so physical names that fold in the stack name inherit the isolation:
export const taskQueue = new Queue({ QueueName: Sub`${AWS.StackName}-tasks`,});Pass env explicitly for a shared long-lived test environment instead — the derivation is a default, not a rule.
Emulator or real cloud
Section titled “Emulator or real cloud”The harness resolves its target the way --live reads do. In order:
- Ambient endpoint variables win. If
AWS_ENDPOINT_URL(or the other lexicons’ endpoint variables) is set, the apply and the teardown go there. This is whatchant emulator up --jsonreports and what the aws lexicon’sflociUpactivity exports, so a suite that boots its own emulator needs nothing else. - The environment’s declared
endpointapplies otherwise. A{ name: "test-*", endpoint: "http://localhost:4566" }entry points every harness run at the local emulator by default, injected only for the duration of the call. - With neither, the real cloud. CI pointing the identical suite at a real account just leaves both unset and provides credentials.
Nothing about the suite text changes between the three.
Crashed-suite recovery
Section titled “Crashed-suite recovery”Teardown is stateless: it reads live ownership markers, never a local record. If a suite crashes after deploying — the process was killed, afterAll never ran — nothing is lost except the sweep itself. Rerun destroy:
chant lifecycle teardown test-my-service-k3x9a2 --yesThe same marker-scoped sweep destroy() runs in-process, against whatever the crashed run left behind. chant lifecycle teardown <env> without --yes shows the plan first.
Where this sits
Section titled “Where this sits”The local-testing overview covers the per-cloud apply paths and emulators the harness rides on; the per-cloud pages (AWS, Azure, GCP) walk each loop by hand. The harness packages that loop for a test runner: same build, same appliers, same teardown, one call each way.