Define a reusable baseline, request it explicitly, and recover according to the mutation that ran it.
Configured seed is a reusable starting point, not an automatic fixture. Creation and reset stay empty unless their owner requests the baseline.
This complete config uses both seed layers. The plugin seed creates Ada and general; the top-level
scenario seed then calls a typed operation that depends on both:
import { slack } from "@localhost2137/slack";
import { defineConfig } from "localhost2137";
export default defineConfig({
clock: { mode: "pinned", startAt: "2026-01-01T00:00:00.000Z" },
services: {
slack: slack({
config: {
botToken: "xoxb-local-seeding-guide",
eventsUrl: null,
signingSecret: "local-seeding-guide-signing-secret",
workspaceName: "Seeded workspace",
},
seed: {
users: [{ id: "U_ADA", name: "Ada" }],
channels: [{ id: "C_GENERAL", name: "general", members: ["U_ADA"] }],
},
}),
},
seed: async (world) => {
await world.slack.sendMessage({
channel: "general",
from: "Ada",
text: "baseline ready",
});
},
});Each installed plugin defines its own seed schema. Plugin seeds run sequentially in service order;
the top-level seed runs only after every plugin seed succeeds. Its facade contains typed service
connections and operations, not lifecycle methods. Keep this scenario small enough that the config
still explains the resulting world.
The checked test starts empty, applies seed in place, proves a second in-place seed is refused, resets to empty, then resets with seed:
import { createTestRuntime } from "localhost2137/testing";
import { expect, it } from "vitest";
import config from "./fixtures/seeding-config.js";
it("applies plugin seed before scenario seed and keeps reset explicit", async () => {
const runtime = await startRuntime();
try {
const instance = await runtime.createInstance();
try {
await expect(readUserNames(instance.slack.connection)).resolves.toEqual([
"localhost2137-bot",
]);
await expect(instance.slack.listMessages({ channel: "general" })).rejects.toMatchObject({
code: "SLACK_CHANNEL_NOT_FOUND",
});
await instance.seed();
await expectSeededBaseline(instance);
await expect(instance.seed()).rejects.toMatchObject({ code: "LIFECYCLE_CONFLICT" });
await instance.reset();
await expect(readUserNames(instance.slack.connection)).resolves.toEqual([
"localhost2137-bot",
]);
await instance.reset({ seed: true });
await expectSeededBaseline(instance);
} finally {
await instance.destroy();
}
} finally {
await runtime.close();
}
});
function startRuntime() {
return createTestRuntime({ config, port: 0, storage: "temporary" });
}
type SeedingRuntime = Awaited<ReturnType<typeof startRuntime>>;
type SeedingInstance = Awaited<ReturnType<SeedingRuntime["createInstance"]>>;
async function expectSeededBaseline(instance: SeedingInstance): Promise<void> {
await expect(readUserNames(instance.slack.connection)).resolves.toEqual([
"localhost2137-bot",
"Ada",
]);
await expect(instance.slack.listMessages({ channel: "general" })).resolves.toMatchObject([
{ text: "baseline ready", userId: "U_ADA" },
]);
}
async function readUserNames(
connection: Readonly<{ apiUrl: string; botToken: string }>,
): Promise<string[]> {
const response = await fetch(new URL("users.list", connection.apiUrl), {
headers: { authorization: `Bearer ${connection.botToken}` },
});
const payload = (await response.json()) as {
members: Array<{ name: string }>;
ok: true;
};
expect(response.status).toBe(200);
expect(payload.ok).toBe(true);
return payload.members.map(({ name }) => name);
}pnpm exec vitest run test/seed-lifecycle.test.tsThe message assertion proves ordering: scenario seed could not post as Ada to general before the
plugin seed created that user, channel, and membership. The empty world still contains the plugin's
required bot identity; empty means configured seed did not run, not that plugin storage has no rows.
Start a daemon from the same checked config the test imports:
# Terminal 1
pnpm exec localhost --config test/fixtures/seeding-config.ts devFrom another terminal, select that config on every command. This shell owns one named world and destroys it at the end:
pnpm exec localhost --config test/fixtures/seeding-config.ts \
instance create seed-guide
# Apply the configured baseline to this unseeded world exactly once
pnpm exec localhost --config test/fixtures/seeding-config.ts \
seed --instance seed-guide
# Replace it with an empty world
pnpm exec localhost --config test/fixtures/seeding-config.ts \
instance reset seed-guide
# Replace it again and apply the configured baseline
pnpm exec localhost --config test/fixtures/seeding-config.ts \
instance reset seed-guide --seed
pnpm exec localhost --config test/fixtures/seeding-config.ts \
instance destroy seed-guideFor a new world that should begin seeded, combine creation and seed:
pnpm exec localhost --config test/fixtures/seeding-config.ts \
instance create review --seed
pnpm exec localhost --config test/fixtures/seeding-config.ts \
instance destroy reviewlocalhost seed without --instance targets persistent dev. Run it only for a currently unseeded
world; successful seed status survives daemon restarts. Do not reset a persistent development world
merely to make a copied seed command rerunnable.
| Mechanism | Use it when | It does not prove |
|---|---|---|
| Plugin seed | Many scenarios share one service-local baseline. | The application can create those records. |
| Top-level scenario seed | One reusable baseline composes operations across mounted services. | Provider-facing application behavior. |
| Operations after creation | Setup or failure state belongs to one scenario. | The application can perform that operation. |
| Application SDK, HTTP, or callback | Creating the state is the behavior under test. | Nothing beyond the plugin's documented compatibility surface. |
Prefer case-specific operations beside the test over turning config seed into a hidden suite. Stable IDs help only when that plugin's seed schema accepts them; do not transfer seed fields or ID rules between plugins.
Service seed is not one transaction across plugins. A later plugin or the top-level scenario can fail after earlier state changed. Recovery depends on the mutation that requested seed:
| Action | Failed result | Recovery |
|---|---|---|
localhost seed or instance.seed() | The existing world remains addressable with seed_failed status and can contain partial changes. Another in-place seed is refused. | Inspect the failure, fix it, then reset. Add seed to that reset only when the baseline can run again from empty state. |
Create with --seed or { seed: true } | Creation fails; no partially seeded new instance becomes addressable. | Fix the cause, then create again. There is no failed new instance to reset. |
Reset with --seed or { seed: true } | The replacement fails. The runtime restores the prior world when it can instead of activating the partial replacement. | Do not assume reset succeeded. Fix and retry. If restoration also failed, resolve that reported failure before using the instance. |
Restart the runtime before recovery when loaded config or plugin code changed. The top-level scenario belongs to the same outer mutation as plugin seed and follows the same row.
Configuration defines seed fields, Instances and isolation defines lifecycle ownership, and Operations and emulated APIs keeps setup separate from application evidence.