Seed test worlds

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.

Define the baseline in config

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:

test/fixtures/seeding-config.ts
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.

Prove the lifecycle in one owned test

The checked test starts empty, applies seed in place, proves a second in-place seed is refused, resets to empty, then resets with seed:

test/seed-lifecycle.test.ts
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.ts

The 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.

Run the same transitions from the CLI

Start a daemon from the same checked config the test imports:

# Terminal 1
pnpm exec localhost --config test/fixtures/seeding-config.ts dev

From 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-guide

For 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 review

localhost 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.

Choose what creates each record

MechanismUse it whenIt does not prove
Plugin seedMany scenarios share one service-local baseline.The application can create those records.
Top-level scenario seedOne reusable baseline composes operations across mounted services.Provider-facing application behavior.
Operations after creationSetup or failure state belongs to one scenario.The application can perform that operation.
Application SDK, HTTP, or callbackCreating 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.

Recover according to the outer mutation

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:

ActionFailed resultRecovery
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.