Identica
Live tests

Writing journeys

Declare a test network, describe a player, and check what the player and the proxy report.

Write a test

A test declares its network with @Identica and describes a player with Journey:

@Test
@Identica(choice = JourneyChoice.PROMPTED, providers = {Provider.CREDENTIAL, Provider.EXAMPLE})
void registersWithCredential(ScenarioContext anvil) {
	Journey.offline(anvil, "Alice").join()
			.on(AUTH)
			.sees(m -> m.identica().getEngine().getJourney().getStep().getEnrollment().getBody())
			.enroll(Provider.CREDENTIAL)
			.sees(m -> m.credential().getScenario().getRegistration().getPrompt())
			.register("Secret-123")
			.on(LOBBY)
			.sees(m -> m.credential().getCompletion().getRegistration().getBody());
}

@Identica sets whether registration asks the player to choose the provider, the enabled providers, provider entrypoints, and the routing targets. With only one provider that can register a player, nobody is asked to choose. The network is one proxy, Velocity unless the test names another platform, an auth server for authentication and registration steps, and a lobby that completed players reach. Players join lobby; step and complete name the servers they are routed to while a step waits and once they are done, auth and lobby unless the test changes them, and a blank target leaves them where they are:

@Test
@Identica(providers = Provider.CREDENTIAL, step = "", complete = "")
void registersOnTheLobbyWithoutAnyRouting(ScenarioContext anvil) {
	Journey.offline(anvil, "Alice").join()
			.on(LOBBY)
			.sees(m -> m.credential().getScenario().getRegistration().getPrompt())
			.register("Secret-123")
			.sees(m -> m.credential().getCompletion().getRegistration().getBody())
			.remainsOn(LOBBY);
}

IdenticaNetwork.CLOSED names a server with a whitelist nobody is on, so it refuses every player. A network only has it when a test routes players there, such as @Identica(complete = IdenticaNetwork.CLOSED), for a journey about a server turning players away.

database picks where the proxy stores its accounts: Database.SQLITE, the default, Database.H2, the H2 file in Identica's data directory, or a server in a container that stops with the network: Database.MARIADB, reached through Identica's MySQL backend, Database.MYSQL or Database.POSTGRESQL. A test on a database server needs Docker and is skipped without it.

Every Velocity test proxy forces the hostname IdenticaNetwork.LOBBY_HOST to lobby; every journey that names no hostname joins through one without a forced host.

Journey.of(anvil, client) creates a player from a Client description: Client.offline(username) or Client.premium(account), then .through(host) for the hostname the client announces, such as LOBBY_HOST or a provider's entrypoint, .from(address) for the loopback address it connects from, such as 127.0.0.2, and .at(proxy) for a proxy of a cluster. Without .from(...) every player connects from 127.0.0.1, so two clients that should look like different machines name different addresses. Clients of one username need different addresses or proxies:

Journey home = Journey.of(anvil, Client.offline("Alice"));
Journey elsewhere = Journey.of(anvil, Client.offline("Alice").from("127.0.0.2"));
Journey routed = Journey.of(anvil, Client.offline("Bob").through(Provider.CREDENTIAL.getEntrypoint()));

Each Journey step checks both sides: what the player's client shows, and what the proxy reports on its own console for connections, server changes, and disconnect reasons. A message expectation follows the player's chat as a vanilla client keeps it, so a message that reaches the client before it joined a server's world, such as while it still configures, never counts as seen. It only considers messages shown since the previous expectation, so a prompt repeated after a reconnect is not satisfied by its earlier occurrence.

A join the proxy may refuse starts with attempt(), or attemptAgain() for a player who connected before, instead of join(). The refusal that follows names its moment: refusedWith(...) before the proxy knows who is joining, refusedAtLoginWith(...) while the player is still logging in, before any backend or prompt, and kickedWith(...) for a disconnect at any point.

A player a checkpoint holds on the loading screen logs in but enters no server's world until the journey has finished. joinsHeld() joins such a player and expects the proxy to hold them, isHeld() checks again that their client entered no world, staysHeld(duration) keeps checking for longer, such as past the proxy's read timeout, and neverJoins(backend) checks that the player never reaches that backend. The example addon's checkpoints hold players who announce IdenticaNetwork.HOLD_HOST on the loading screen and players who announce IdenticaNetwork.ASK_HOST on the step's server, and Administrator.answersFor(username, command) answers for them from the console:

@Identica(providers = Provider.CREDENTIAL, addon = true)
void holdsAPlayerUntilTheyHaveRegistered(ScenarioContext anvil) {
	Journey alice = Journey.of(anvil, Client.offline("Alice").through(HOLD_HOST)).joinsHeld();
	Administrator console = Administrator.at(anvil, PROXY);

	console.answersFor("Alice", "pass Secret-123");
	alice.isHeld();
	console.answersFor("Alice", "passconfirm Secret-123");

	alice.on(LOBBY).neverJoins(AUTH);
}

Use Accounts for players who already have an account.

A player whose username Identica changes, such as an account a username conflict renamed, joins under another name than its client's. .named(username) tells the journey the name the proxy reports the player under from the next connection on, and .playsAs(username) expects the backend to see the player under it. ProxyConfiguration.usernameConflicts() changes the username conflict rules of a test network:

holder.named("Steve_credential").rejoin()
		.on(AUTH)
		.login(PASSWORD)
		.on(LOBBY)
		.playsAs("Steve_credential");

The auth and lobby servers remember earlier tests: a player's position and inventory there belong to no test. Keep expectations on what the proxy and the player's client report, and do not rely on the state of a backend world.

A message expectation names the message through Identica's own configuration models: m.identica() is Messages, m.username() is UsernameMessages, m.credential() is CredentialMessages, m.premium() is PremiumMessages, m.sentinel() is SentinelMessages, m.verification() is VerificationMessages, m.joinRestriction() is JoinRestrictionMessages and m.example() is the example provider's ExampleMessages. The models are read from the files the running proxy generated, so the expectation follows the configured text, and a renamed or removed message stops the tests from compiling. Formatting tags are ignored and placeholders match any text. Chain .with(...) and .without(...) after .sees(...) to check what else the same message contains. Do not copy message text into a test.

A player reads what a message hands out, such as a secret or a list of codes, with .reads(placeholder) after the .sees(...) that matched it. Authenticator stands in for an authenticator app, computing the codes for a secret the player read:

Authenticator app = Authenticator.of(alice.command("2fa enroll totp")
		.sees(m -> m.verification().getMethods().getTotp().getPending())
		.reads("secret"));
alice.command("2fa enroll confirm " + app.code());

Identica accepts each code once, the confirming code included, so a journey that types a second code takes app.nextCode(), which waits until the app shows the code after the last one it returned. app.usedCode() is the code it returned last, to type it again on purpose; app.codeAhead() is the code of an app whose clock runs a window ahead; app.wrongCode() is a code Identica accepts in no window. The app changes codes every five seconds, so the waits stay short; give the network the same period with configure = Authenticator.ShortPeriod.class, which also widens the drift window into the past so a reused code is only refused because it was used.

Premium accounts

No test needs a real Minecraft account. Every network starts its own local stand-in for Mojang: the Premium provider asks it whether a username belongs to a premium account, and the proxy asks it to verify a premium login instead of Mojang's session server.

A test takes the stand-in as a parameter and registers the premium accounts that exist:

@Test
@Identica
void joins(ScenarioContext anvil, YggdrasilMock yggdrasil) {
	SessionIdentity account = yggdrasil.register("Steve");
	Journey.premium(anvil, account).join().on(LOBBY);
}

yggdrasil.register("Alice") alone makes Alice a premium username that an offline player can still try to join with, and yggdrasil.available(false) simulates an outage of Mojang's services. The stand-in belongs to the test's network: it starts without accounts and stops with the network.

Change the configuration

@Identica covers the usual differences between networks. For anything else, a test names classes that change the proxy's configuration files through Identica's own models. A file the test leaves out is generated by the plugin, as on a new installation:

@Test
@Identica(configure = NoDisconnectMessages.class)
void endingASessionDisconnectsThePlayer(ScenarioContext anvil) {
}

private static final class NoDisconnectMessages implements Consumer<ProxyConfiguration> {
	@Override
	public void accept(ProxyConfiguration proxy) {
		proxy.messages().getCommands().getAdmin().getSessions().getEnd().setDisconnect(List.of());
	}
}

proxy.engine(), proxy.routing(), proxy.commands(), proxy.settings(), proxy.providers(), proxy.messages(), proxy.credential() (the Credential provider's settings), proxy.sentinel(), proxy.recognition(), proxy.verification() and proxy.joinRestriction() (those features' shared settings) return the models a test changes. proxy.put(path, model) adds another file, proxy.omit(path) leaves a file out, proxy.corrupt(path, content) writes one Identica cannot read, proxy.seed(path, file) places a file, such as a database an earlier version left, before the proxy first starts, and ProxyConfiguration.generated(anvil, path, type) reads a file as the running proxy left it. While the network runs, ProxyConfiguration.editGenerated(anvil, path, type, change) changes a file through its model and ProxyConfiguration.corruptGenerated(anvil, path, content) replaces it with content Identica cannot read; Administrator.at(anvil, PROXY).reloads() and .failsToReload() then reload the configuration and expect the configured answer.

A journey about a lockout or a validity window configures a short duration and lets it pass with .waits(duration); it never waits to give the proxy time to act, because every other step already waits for that:

alice.waits(LOCKOUT)
		.rejoin()
		.on(AUTH)
		.login("Secret-123")
		.on(LOBBY);

The examples

Every test proxy installs the example provider from examples/provider next to the official ones, disabled unless the test enables it with providers = Provider.EXAMPLE. @Identica(addon = true) also installs the example addon from examples/addon, a separate plugin that holds back chat and commands on Velocity until a player has authenticated, and holds players who join through IdenticaNetwork.HOLD_HOST or IdenticaNetwork.ASK_HOST at its checkpoints on Velocity and BungeeCord. The tests under example keep both working against the current API.

Text that belongs to no configuration file, such as an addon's own message or a chat line, is matched as plain text. .chat(message) sends a chat message, .seesText(text) waits for a message containing the text, and .doesNotSeeText(text) expects none to arrive. Administrator.seesOnConsole(texts...) expects a console line containing every text since the administrator's previous command, such as what an addon logs when it reacts to a reload:

@Test
@Identica(providers = Provider.EXAMPLE, addon = true)
void holdsBackChat(ScenarioContext anvil) {
	Journey.offline(anvil, "Alice").join()
			.on(AUTH)
			.sees(m -> m.example().getPrompt())
			.chat("hello")
			.doesNotSeeText("hello")
			.seesText("Log in before you chat.");
}

On this page