Real stacks
Several apps
Start a frontend, an API and workers together with stackEnvironment, in order, wired to each other, behind one proxy.
stackEnvironment starts several apps for each worker: a frontend, the API it calls, background
workers, a local dev server. It puts them all behind one proxy and stops them when the run ends.
List the apps
Each app is a command danwo runs, the address it answers on, and a name:
import { defineConfig, stackEnvironment, stripeService } from "@danwo/sdk"
export default defineConfig({
testDir: "tests",
environment: () =>
stackEnvironment({
services: [stripeService()],
apps: [
{ name: "web", command: "pnpm dev", cwd: "apps/web", url: "http://localhost:3000" },
{ name: "api", command: "pnpm start", cwd: "apps/api", url: "http://localhost:4000" },
],
}),
})The command runs in a shell, in cwd, with PORT set to the port of its url. It inherits the
proxy's environment, so its calls to Stripe reach the emulator.
Start them in order
An API usually needs its worker up first, and a frontend needs the API. Say so with dependsOn:
apps: [
{ name: "web", command: "pnpm dev", url: "http://localhost:3000", dependsOn: ["api"] },
{ name: "api", command: "pnpm start", url: "http://localhost:4000", dependsOn: ["worker"] },
{ name: "worker", command: "pnpm worker", url: "http://localhost:4100" },
]danwo starts worker, then api, then web. Apps with nothing between them start together. A
cycle, or a name that isn't in the list, fails with an error naming it.
Say when an app is ready
By default an app is ready when its url answers. Change that with ready:
ready | Ready when | Use it for |
|---|---|---|
| left out | url answers any HTTP request | most servers |
{ path: "/health" } | that path answers | an app with a health check |
{ log: "worker ready" } | that text (or a RegExp) appears in its output | a worker that serves nothing |
{ exit: true } | its command exits with code 0 | docker compose up --wait |
An app has two minutes to be ready, for a dev server's first build. Change it with timeoutMs.
If it isn't ready by then, the error ends with the last lines it printed, or says it printed
nothing.
Start containers with a command that returns
docker compose up --wait starts its containers, waits until they are healthy and then exits.
Give it ready: { exit: true }, and a stop command, since the containers outlive it:
{
name: "db",
command: "docker compose up --wait --wait-timeout 60",
stop: "docker compose down",
url: "http://localhost:5432",
ready: { exit: true },
}Its exit doesn't count as the app going down. danwo runs stop when the run ends, and also when
the app fails to start. Every command gets an empty stdin, so nothing waits on a prompt.
--wait alone waits forever on a container that never turns healthy. Add --wait-timeout, so
compose says which container it was.
Keep the runner's secrets from the app
By default an app inherits danwo's whole environment, so its own variables (a DATABASE_URL, a
.env you exported) just work. So do the runner's: a CI token, or danwo's own model key. Set
inheritEnv to keep those out:
{
name: "web",
command: "pnpm dev",
url: "http://localhost:3000",
inheritEnv: ["DATABASE_URL", "NEXT_PUBLIC_*"],
env: { STRIPE_SECRET_KEY: "sk_test_danwo" },
}inheritEnv | The app inherits |
|---|---|
true (default) | everything danwo's process has |
false | only what a shell needs: PATH, HOME, the temp folder, the locale |
| a list of names | that, plus the names listed; NEXT_PUBLIC_* matches a prefix |
danwo's proxy variables and the app's own env always go on top.
The default is convenient, and it is a trade-off. Whatever your CI exports (GITHUB_TOKEN, cloud
keys, AI_GATEWAY_API_KEY) reaches the app under test, which may log it or send it on. Records
hide only the secrets danwo knows. On shared CI, prefer inheritEnv: false and name what the app
needs.
Wire one app to another
env adds variables to an app's environment. Make it a function and it receives every app's
address by name:
{
name: "web",
command: "pnpm dev",
url: "http://localhost:3000",
dependsOn: ["api"],
env: ({ api }) => ({ VITE_API_URL: api ?? "" }),
}The frontend now calls the API that this worker started, not one on a fixed port. A
NODE_OPTIONS you set here is joined with danwo's, not replaced.
Choose the page the browser opens
The browser opens the first app's address. Name another with baseUrl:
stackEnvironment({ baseUrl: "web", apps: [api, web] })Tests get every app's address as the apps fixture, so an invariant can ask the API directly:
import { test as base } from "@danwo/sdk"
const test = base.extend<{ readonly apps: Readonly<Record<string, string>> }>()
test("adds a task", async ({ app, screen, expect }) => {
await app.open("/")
await screen.getByRole("button", { name: "Add task" }).click()
await expect(screen.getByRole("status")).toHaveText("Task saved")
}).invariant(async ({ apps, expect }) => {
const tasks = await fetch(`${apps.api ?? ""}/tasks`)
expect(tasks.status).toBe(200)
})When an app fails to start
The test file fails with the app's name, why it stopped, and the last lines of its output. The run exits with code 2:
✗ tests/tasks.danwo.ts: worker 0 failed to start: Error: api: it exited with code 1 before http://localhost:4000 answered
Error [ERR_MODULE_NOT_FOUND]: Cannot find module '…/apps/api/dist/main.js'What each app printed while it started is kept in .danwo/runs/<run>/environment-<worker>.json,
apart from the tests' own records.
Options
| Option | What it does | Default |
|---|---|---|
apps | the apps to start | required |
services | the emulators behind the proxy (stripeService(), clerkService()) | none |
baseUrl | the name of the app the browser opens | the first app |
unmatched | what happens to hosts no service answers: passthrough or block | passthrough |
allow, blocked, telemetry | which outside hosts pass or are refused | see Outside hosts |
assets | whether a page's images and fonts still load under block | passthrough |
secrets | live-looking keys an app would use: warn, fail or ignore | warn |
nodeShim | keep danwo's preload even when a script sets NODE_OPTIONS | true |