diff --git a/README.md b/README.md index 5d13067f..684c3e7e 100644 --- a/README.md +++ b/README.md @@ -46,7 +46,8 @@ planned and reviewed by a human. - **World & Chunk Control**: Load/unload chunks, check chunk status, and teleport players between worlds. - **Inventory & Item Drops**: Inspect player inventories and simulate item drops. - **Dynamic Event Capture**: Capture and assert on any Bukkit event dynamically without writing custom listeners. -- **Clickable Chat Assertions**: Verify clickable chat message text and actions. +- **Clickable Chat Assertions**: Verify clickable chat message text and actions, extract a click action, and click it. +- **Command Tab-Completion**: Query a player's permission-filtered command completions exactly as a real client would. - **Platform Awareness**: Write tests that adapt to Paper or Spigot specifics. ## Requirements @@ -248,6 +249,13 @@ class MyPluginIT - Bot chat and deterministic cancellation: `player.chat("...")` fires the real chat event; `capture.cancelNext(n)` cancels exactly the next N events of a captured class (LOWEST priority, so the capture still records the final cancelled state); captured events carry the server tick they fired on +- Command tab-completion: `player.tabComplete("/cmd")` returns the live server command map's completions for that + player, permission-filtered exactly as a real client's tab-complete would be — a bot that lacks a command's + permission never sees it. A leading slash is accepted and normalized; a trailing space selects argument + completion (and is never trimmed), while an unknown command yields an empty list +- Clickable chat components: capture a player's rendered chat components with `player.chatComponents()`, extract a + click action from a snapshot with `component.clickRunCommand()`/`component.clickSuggestedCommand()`, and dispatch + a `run_command` click as the player with `player.clickChatComponent(component)` — the same path a real click takes - Block state as a first-class value: place blocks with full state via `world.setBlockAt(pos, BlockSpec.parse("minecraft:lever[face=floor,powered=true]"))` and assert partially via `world.blockAt(pos).is(BlockSpec.parse("minecraft:lever[powered=true]"))` — only named properties diff --git a/lightkeeper-agent-spigot/src/main/java/nl/pim16aap2/lightkeeper/agent/spigot/AgentPlayerActions.java b/lightkeeper-agent-spigot/src/main/java/nl/pim16aap2/lightkeeper/agent/spigot/AgentPlayerActions.java index d041f998..c641376e 100644 --- a/lightkeeper-agent-spigot/src/main/java/nl/pim16aap2/lightkeeper/agent/spigot/AgentPlayerActions.java +++ b/lightkeeper-agent-spigot/src/main/java/nl/pim16aap2/lightkeeper/agent/spigot/AgentPlayerActions.java @@ -14,6 +14,7 @@ import nl.pim16aap2.lightkeeper.protocol.PlayerChat; import nl.pim16aap2.lightkeeper.protocol.RemovePlayer; import nl.pim16aap2.lightkeeper.protocol.RightClickBlock; +import nl.pim16aap2.lightkeeper.protocol.TabCompletePlayer; import nl.pim16aap2.lightkeeper.protocol.TeleportPlayer; import org.bukkit.Bukkit; import org.bukkit.Location; @@ -21,6 +22,7 @@ import org.bukkit.World; import org.bukkit.block.Block; import org.bukkit.block.BlockFace; +import org.bukkit.command.CommandMap; import org.bukkit.entity.Player; import org.bukkit.event.EventPriority; import org.bukkit.event.HandlerList; @@ -35,6 +37,7 @@ import org.jspecify.annotations.Nullable; import java.time.Duration; +import java.util.List; import java.util.Objects; import java.util.Set; import java.util.UUID; @@ -73,6 +76,15 @@ final class AgentPlayerActions * connection; their removal must run the real disconnect flow instead of the legacy player-list removal. */ private final Set fullLoginPlayerIds = ConcurrentHashMap.newKeySet(); + /** + * The server's {@link CommandMap}, resolved lazily and cached on first tab-complete. + * + *

{@code CraftServer#getCommandMap()} is not declared on the {@code org.bukkit.Server} interface on either + * distribution (Spigot returns a versioned {@code SimpleCommandMap}, Paper an unversioned {@code CommandMap}), + * so it can only be reached reflectively off the live server instance; the {@code CommandMap} interface it + * returns is regular spigot-api, so no per-version NMS module is required. + */ + private @Nullable CommandMap cachedCommandMap; /** * @param plugin @@ -485,6 +497,84 @@ ExecutePlayerCommand.Response handleExecutePlayerCommand(ExecutePlayerCommand.Co return new ExecutePlayerCommand.Response(dispatched); } + /** + * Handles {@code TAB_COMPLETE_PLAYER} by computing command tab-completions in the player's context. + * + *

Any single leading slash is stripped before the buffer reaches {@code CommandMap#tabComplete}, mirroring + * {@link #handleExecutePlayerCommand}: the map matches command names without a slash, and its args-completion + * branch does a raw {@code knownCommands.get} lookup that a leading slash would defeat. The remaining + * whitespace of the buffer is preserved verbatim, since a trailing space selects argument completion rather + * than command-name completion. A {@code null} result (unknown command, or a plugin {@code TabCompleter} + * returning {@code null}) is normalized to an empty list. Completions are permission-filtered for the + * synthetic player exactly as they would be for a real client. + * + *

No {@code TabCompleteEvent}/{@code AsyncTabCompleteEvent} is fired: this path exercises registered + * commands and {@code TabCompleter}s, not plugins that customize completions purely through those events. + * + * @param command + * Typed command carrying the player UUID and the raw command-line buffer. + * @return Response containing the ordered tab-completion suggestions. + * + * @throws Exception + * Propagates main-thread execution failures, and {@link IllegalStateException} when the server + * {@code CommandMap} cannot be resolved. + */ + TabCompletePlayer.Response handleTabCompletePlayer(TabCompletePlayer.Command command) + throws Exception + { + final UUID uuid = command.uuid(); + final String rawCommandLine = command.commandLine(); + final String commandLine = rawCommandLine.startsWith("/") ? rawCommandLine.substring(1) : rawCommandLine; + + final List completions = mainThreadExecutor.callOnMainThread(() -> + { + final Player player = playerStore.getRequiredPlayer(uuid); + final CommandMap commandMap = resolveCommandMap(); + final List result = commandMap.tabComplete(player, commandLine); + return result == null ? List.of() : List.copyOf(result); + }); + + return new TabCompletePlayer.Response(completions); + } + + /** + * Resolves the server's {@link CommandMap}, caching it after the first lookup. + * + *

The accessor lives on the concrete {@code CraftServer} (versioned on Spigot, unversioned on Paper) and + * not on the {@code org.bukkit.Server} interface the agent compiles against, so it is reached reflectively. + * A missing or renamed accessor on a future fork fails loudly here on first use instead of silently + * returning empty completions forever. + * + * @return The live server command map. + * @throws IllegalStateException + * When the accessor is absent, inaccessible, or returns something other than a {@link CommandMap}. + */ + private CommandMap resolveCommandMap() + { + final CommandMap resolved = cachedCommandMap; + if (resolved != null) + return resolved; + + try + { + final Object server = Bukkit.getServer(); + final Object rawCommandMap = server.getClass().getMethod("getCommandMap").invoke(server); + if (!(rawCommandMap instanceof CommandMap commandMap)) + throw new IllegalStateException( + "Server#getCommandMap() returned %s, which is not a CommandMap." + .formatted(rawCommandMap == null ? "null" : rawCommandMap.getClass().getName())); + cachedCommandMap = commandMap; + return commandMap; + } + catch (ReflectiveOperationException exception) + { + throw new IllegalStateException( + "Failed to resolve the server CommandMap via reflection; tab-completion is unavailable on this " + + "server distribution.", + exception); + } + } + /** * Handles {@code PLACE_PLAYER_BLOCK} by setting the target block type in the player's current world. * diff --git a/lightkeeper-agent-spigot/src/main/java/nl/pim16aap2/lightkeeper/agent/spigot/AgentRequestDispatcher.java b/lightkeeper-agent-spigot/src/main/java/nl/pim16aap2/lightkeeper/agent/spigot/AgentRequestDispatcher.java index 5f594e7b..4e702446 100644 --- a/lightkeeper-agent-spigot/src/main/java/nl/pim16aap2/lightkeeper/agent/spigot/AgentRequestDispatcher.java +++ b/lightkeeper-agent-spigot/src/main/java/nl/pim16aap2/lightkeeper/agent/spigot/AgentRequestDispatcher.java @@ -41,6 +41,7 @@ import nl.pim16aap2.lightkeeper.protocol.RemovePlayer; import nl.pim16aap2.lightkeeper.protocol.RightClickBlock; import nl.pim16aap2.lightkeeper.protocol.SetBlock; +import nl.pim16aap2.lightkeeper.protocol.TabCompletePlayer; import nl.pim16aap2.lightkeeper.protocol.TeleportPlayer; import nl.pim16aap2.lightkeeper.protocol.UnloadChunk; import nl.pim16aap2.lightkeeper.protocol.UnregisterEventListener; @@ -281,6 +282,7 @@ private RequestDispatchResult dispatchCommand(IAgentCommand command, boolean han case CancelNextEvents.Command c -> handle(c, eventActions::handleCancelNextEvents); case PlayerChat.Command c -> handle(c, playerActions::handlePlayerChat); case QueryEntities.Command c -> handle(c, worldActions::handleQueryEntities); + case TabCompletePlayer.Command c -> handle(c, playerActions::handleTabCompletePlayer); case Handshake.Command ignored -> throw new IllegalStateException("Unreachable HANDSHAKE dispatch branch."); }; diff --git a/lightkeeper-agent-spigot/src/test/java/nl/pim16aap2/lightkeeper/agent/spigot/AgentPlayerActionsTest.java b/lightkeeper-agent-spigot/src/test/java/nl/pim16aap2/lightkeeper/agent/spigot/AgentPlayerActionsTest.java index 56e2f36e..100acfe0 100644 --- a/lightkeeper-agent-spigot/src/test/java/nl/pim16aap2/lightkeeper/agent/spigot/AgentPlayerActionsTest.java +++ b/lightkeeper-agent-spigot/src/test/java/nl/pim16aap2/lightkeeper/agent/spigot/AgentPlayerActionsTest.java @@ -22,12 +22,15 @@ import nl.pim16aap2.lightkeeper.protocol.PlayerChat; import nl.pim16aap2.lightkeeper.protocol.RemovePlayer; import nl.pim16aap2.lightkeeper.protocol.RightClickBlock; +import nl.pim16aap2.lightkeeper.protocol.TabCompletePlayer; import nl.pim16aap2.lightkeeper.protocol.TeleportPlayer; import org.bukkit.Bukkit; import org.bukkit.Material; +import org.bukkit.Server; import org.bukkit.World; import org.bukkit.block.Block; import org.bukkit.block.BlockFace; +import org.bukkit.command.CommandMap; import org.bukkit.entity.Player; import org.bukkit.event.Listener; import org.bukkit.event.block.Action; @@ -107,6 +110,111 @@ void handleExecutePlayerCommand_shouldFallBackToServerDispatcherWhenPerformComma assertThat(response.dispatched()).isTrue(); } + @Test + void handleTabCompletePlayer_shouldStripLeadingSlashAndReturnCompletions() + throws Exception + { + // setup + final PlayerActionsFixture fixture = createPlayerActionsFixture(); + final UUID uuid = UUID.randomUUID(); + final Player player = mock(); + fixture.playerStore().registerSyntheticPlayer(uuid, player); + final CommandMap commandMap = mock(); + final TabCompleteServer server = mock(); + when(server.getCommandMap()).thenReturn(commandMap); + when(commandMap.tabComplete(player, "lktestg")).thenReturn(List.of("/lktestgui")); + final TabCompletePlayer.Command command = + new TabCompletePlayer.Command("request-tc", uuid, "/lktestg"); + + // execute + final TabCompletePlayer.Response response; + try (MockedStatic bukkit = mockStatic(Bukkit.class)) + { + bukkit.when(Bukkit::isPrimaryThread).thenReturn(true); + bukkit.when(Bukkit::getServer).thenReturn(server); + response = fixture.playerActions().handleTabCompletePlayer(command); + } + + // verify — the agent strips the single leading slash before the CommandMap lookup + assertThat(response.completions()).containsExactly("/lktestgui"); + verify(commandMap).tabComplete(player, "lktestg"); + } + + @Test + void handleTabCompletePlayer_shouldReturnEmptyListWhenCommandMapReturnsNull() + throws Exception + { + // setup — an unknown command's args-completion branch yields a null result that must not propagate + final PlayerActionsFixture fixture = createPlayerActionsFixture(); + final UUID uuid = UUID.randomUUID(); + final Player player = mock(); + fixture.playerStore().registerSyntheticPlayer(uuid, player); + final CommandMap commandMap = mock(); + final TabCompleteServer server = mock(); + when(server.getCommandMap()).thenReturn(commandMap); + when(commandMap.tabComplete(player, "totallyNotACommand arg")).thenReturn(null); + final TabCompletePlayer.Command command = + new TabCompletePlayer.Command("request-tc-null", uuid, "/totallyNotACommand arg"); + + // execute + final TabCompletePlayer.Response response; + try (MockedStatic bukkit = mockStatic(Bukkit.class)) + { + bukkit.when(Bukkit::isPrimaryThread).thenReturn(true); + bukkit.when(Bukkit::getServer).thenReturn(server); + response = fixture.playerActions().handleTabCompletePlayer(command); + } + + // verify + assertThat(response.completions()).isEmpty(); + } + + @Test + void handleTabCompletePlayer_shouldPreserveTrailingSpaceAfterStrippingSlash() + throws Exception + { + // setup — a trailing space selects argument completion and must survive the slash strip + final PlayerActionsFixture fixture = createPlayerActionsFixture(); + final UUID uuid = UUID.randomUUID(); + final Player player = mock(); + fixture.playerStore().registerSyntheticPlayer(uuid, player); + final CommandMap commandMap = mock(); + final TabCompleteServer server = mock(); + when(server.getCommandMap()).thenReturn(commandMap); + when(commandMap.tabComplete(player, "gamemode ")).thenReturn(List.of()); + final TabCompletePlayer.Command command = + new TabCompletePlayer.Command("request-tc-space", uuid, "/gamemode "); + + // execute + try (MockedStatic bukkit = mockStatic(Bukkit.class)) + { + bukkit.when(Bukkit::isPrimaryThread).thenReturn(true); + bukkit.when(Bukkit::getServer).thenReturn(server); + fixture.playerActions().handleTabCompletePlayer(command); + } + + // verify + verify(commandMap).tabComplete(player, "gamemode "); + } + + @Test + void handleTabCompletePlayer_shouldPropagateWhenPlayerUnknown() + { + // setup + final AgentPlayerActions playerActions = createPlayerActions(); + final TabCompletePlayer.Command command = + new TabCompletePlayer.Command("request-tc-unknown", UUID.randomUUID(), "/help"); + + // execute + verify + try (MockedStatic bukkit = mockStatic(Bukkit.class)) + { + bukkit.when(Bukkit::isPrimaryThread).thenReturn(true); + assertThatThrownBy(() -> playerActions.handleTabCompletePlayer(command)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("not registered"); + } + } + @Test void handleLeftClickBlock_shouldFirePlayerInteractEvent() throws Exception @@ -903,4 +1011,14 @@ private record PlayerActionsFixture( IBotPlayerNmsAdapter nmsAdapter) { } + + /** + * Mockable stand-in for the concrete {@code CraftServer} whose {@code getCommandMap()} accessor lives off the + * {@code org.bukkit.Server} interface. The agent reaches it reflectively; mocking this subtype lets + * {@code server.getClass().getMethod("getCommandMap")} resolve to a stubbed {@link CommandMap}. + */ + interface TabCompleteServer extends Server + { + CommandMap getCommandMap(); + } } diff --git a/lightkeeper-framework-junit/src/main/java/nl/pim16aap2/lightkeeper/framework/ChatComponentSnapshot.java b/lightkeeper-framework-junit/src/main/java/nl/pim16aap2/lightkeeper/framework/ChatComponentSnapshot.java index 1d52582b..0916d169 100644 --- a/lightkeeper-framework-junit/src/main/java/nl/pim16aap2/lightkeeper/framework/ChatComponentSnapshot.java +++ b/lightkeeper-framework-junit/src/main/java/nl/pim16aap2/lightkeeper/framework/ChatComponentSnapshot.java @@ -1,8 +1,18 @@ package nl.pim16aap2.lightkeeper.framework; +import tools.jackson.core.JacksonException; +import tools.jackson.databind.JsonNode; +import tools.jackson.databind.ObjectMapper; + +import java.util.Optional; + /** * Snapshot of a chat component. * + *

The {@link #json()} is the server's native component-codec output, verbatim from the outbound packet. On + * Minecraft 1.21.5+ that codec spells the click field {@code click_event} (snake_case), so the click helpers on + * this record key on {@code click_event}, not the pre-1.21.5 {@code clickEvent}. + * * @param json * The raw JSON representation of the component. */ @@ -10,4 +20,91 @@ public record ChatComponentSnapshot( String json ) { + /** + * Shared JSON mapper for click-event inspection, matching the shared-mapper convention used elsewhere in the + * framework instead of allocating a fresh mapper per call. + */ + private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + + /** + * Modern and legacy click-event key spellings, in preference order. + */ + private static final java.util.List CLICK_EVENT_KEYS = java.util.List.of("click_event", "clickEvent"); + + + /** + * The {@code click_event} action name of a component that runs a command when clicked. + */ + private static final String RUN_COMMAND_ACTION = "run_command"; + /** + * The {@code click_event} action name of a component that fills the chat box with a command when clicked. + */ + private static final String SUGGEST_COMMAND_ACTION = "suggest_command"; + + /** + * Extracts the command of the first {@code run_command} click event in this component's tree. + * + * @return The command to run (typically leading-slash prefixed), or empty when this component carries no + * {@code run_command} click event or its JSON cannot be parsed. + */ + public Optional clickRunCommand() + { + return findClickCommand(RUN_COMMAND_ACTION); + } + + /** + * Extracts the command of the first {@code suggest_command} click event in this component's tree. + * + *

The payload is returned verbatim, including any trailing space (suggest-command payloads often end with + * a space to prime the next argument), so it is never trimmed. + * + * @return The command to suggest, or empty when this component carries no {@code suggest_command} click event + * or its JSON cannot be parsed. + */ + public Optional clickSuggestedCommand() + { + return findClickCommand(SUGGEST_COMMAND_ACTION); + } + + /** + * Finds the command payload of the first click event matching the requested action anywhere in the tree. + * + * @param action + * The {@code click_event} action name to match. + * @return The matching click event's command payload, or empty when none matches. + */ + private Optional findClickCommand(String action) + { + try + { + final JsonNode root = OBJECT_MAPPER.readTree(json); + // findValues walks the whole tree, so a click_event nested in an 'extra' child is found, not only a + // root-level one. Matching per action (rather than "the first click_event found") means + // clickRunCommand() never returns a suggest_command payload when a single message carries both + // (for example a paginated [<] [1/3] [>] control). + // Modern (1.21.5+) payloads use click_event with a 'command' field; legacy payloads used + // clickEvent with a 'value' field. Extraction accepts both so it matches exactly what + // PlayerHandleAssert#hasClickableChatText accepts — an asymmetry there would make + // clickChatComponent throw on the very payloads the assertion matches. + for (final String key : CLICK_EVENT_KEYS) + { + for (final JsonNode clickEvent : root.findValues(key)) + { + if (!action.equals(clickEvent.path("action").asString(""))) + continue; + if (clickEvent.hasNonNull("command")) + return Optional.of(clickEvent.get("command").asString()); + if (clickEvent.hasNonNull("value")) + return Optional.of(clickEvent.get("value").asString()); + } + } + return Optional.empty(); + } + catch (JacksonException malformed) + { + // Malformed component JSON degrades to "no click command", matching the framework's existing + // error-tolerance for this data (see PlayerHandleAssert's parse-failure handling). + return Optional.empty(); + } + } } diff --git a/lightkeeper-framework-junit/src/main/java/nl/pim16aap2/lightkeeper/framework/IFrameworkGatewayView.java b/lightkeeper-framework-junit/src/main/java/nl/pim16aap2/lightkeeper/framework/IFrameworkGatewayView.java index 991a975b..4c16ffa6 100644 --- a/lightkeeper-framework-junit/src/main/java/nl/pim16aap2/lightkeeper/framework/IFrameworkGatewayView.java +++ b/lightkeeper-framework-junit/src/main/java/nl/pim16aap2/lightkeeper/framework/IFrameworkGatewayView.java @@ -40,6 +40,13 @@ public interface IFrameworkGatewayView */ void executePlayerCommand(UUID playerId, String command); + /** + * Computes command tab-completions as a synthetic player. + * + * @return The tab-completion suggestions in server order, permission-filtered for the player. + */ + List tabComplete(UUID playerId, String commandLine); + /** * Grants a permission node to a synthetic player by setting it to {@code true} on the player's attachment. */ diff --git a/lightkeeper-framework-junit/src/main/java/nl/pim16aap2/lightkeeper/framework/PlayerHandle.java b/lightkeeper-framework-junit/src/main/java/nl/pim16aap2/lightkeeper/framework/PlayerHandle.java index 966fa1f3..a51aee79 100644 --- a/lightkeeper-framework-junit/src/main/java/nl/pim16aap2/lightkeeper/framework/PlayerHandle.java +++ b/lightkeeper-framework-junit/src/main/java/nl/pim16aap2/lightkeeper/framework/PlayerHandle.java @@ -71,6 +71,24 @@ public PlayerHandle executeCommand(String command) return this; } + /** + * Computes command tab-completions for this player as if they had typed the given command-line buffer. + * + *

The suggestions come from the live server command map and are permission-filtered for this player, so a + * bot that lacks a command's permission never sees it completed. A leading slash is accepted and normalized + * away agent-side, so {@code "/gamemode"} and {@code "gamemode"} behave identically; the buffer is otherwise + * passed through unmodified, so a trailing space (which selects argument completion rather than command-name + * completion) is preserved. + * + * @param commandLine + * The command-line buffer to complete; must not be blank. + * @return The tab-completion suggestions in server order; empty for an unknown command. + */ + public List tabComplete(String commandLine) + { + return frameworkGateway.tabComplete(uniqueId, commandLine); + } + /** * Teleports this player to target coordinates in a world. * @@ -419,6 +437,27 @@ public List chatComponents() return frameworkGateway.playerChatComponents(uniqueId); } + /** + * Clicks the {@code run_command} action of a captured chat component by extracting its command and executing + * it as this player, exactly as a real player clicking the message would. + * + *

Reuses the same execution path as {@link #executeCommand(String)} (no new protocol command), so the + * extracted command is dispatched in this player's context with a leading slash normalized away. + * + * @param component + * The captured chat component to click; must carry a {@code run_command} click event. + * @return This handle for fluent chaining. + * @throws IllegalArgumentException + * When the component carries no {@code run_command} click event to execute. + */ + public PlayerHandle clickChatComponent(ChatComponentSnapshot component) + { + Objects.requireNonNull(component, "component may not be null."); + final String command = component.clickRunCommand().orElseThrow(() -> new IllegalArgumentException( + "Chat component carries no run_command click event to execute: " + component.json())); + return executeCommand(command); + } + /** * Gets all recorded messages flattened into a single multi-line string. * diff --git a/lightkeeper-framework-junit/src/main/java/nl/pim16aap2/lightkeeper/framework/assertions/PlayerHandleAssert.java b/lightkeeper-framework-junit/src/main/java/nl/pim16aap2/lightkeeper/framework/assertions/PlayerHandleAssert.java index dd95a942..1da55391 100644 --- a/lightkeeper-framework-junit/src/main/java/nl/pim16aap2/lightkeeper/framework/assertions/PlayerHandleAssert.java +++ b/lightkeeper-framework-junit/src/main/java/nl/pim16aap2/lightkeeper/framework/assertions/PlayerHandleAssert.java @@ -113,9 +113,13 @@ public PlayerHandleAssert hasClickableChatText(String expectedText) continue; try { - // findValue matches a clickEvent nested in an 'extra' child, not only the root object; NMS - // serializes components that carry their clickable text in extra children. - if (OBJECT_MAPPER.readTree(component.json()).findValue("clickEvent") != null) + // findValue matches a click event nested in an 'extra' child, not only the root object; NMS + // serializes components that carry their clickable text in extra children. The native component + // codec renamed this field click_event (snake_case) in 1.21.5+, leaving the old clickEvent + // (camelCase) only on pre-1.21.5 payloads; accept both so the assertion matches across codec + // versions (keying on the old name alone silently never matched on 1.21.11). + final var tree = OBJECT_MAPPER.readTree(component.json()); + if (tree.findValue("click_event") != null || tree.findValue("clickEvent") != null) { found = true; break; diff --git a/lightkeeper-framework-junit/src/main/java/nl/pim16aap2/lightkeeper/framework/internal/DefaultLightkeeperFramework.java b/lightkeeper-framework-junit/src/main/java/nl/pim16aap2/lightkeeper/framework/internal/DefaultLightkeeperFramework.java index 208fbced..696cae68 100644 --- a/lightkeeper-framework-junit/src/main/java/nl/pim16aap2/lightkeeper/framework/internal/DefaultLightkeeperFramework.java +++ b/lightkeeper-framework-junit/src/main/java/nl/pim16aap2/lightkeeper/framework/internal/DefaultLightkeeperFramework.java @@ -266,6 +266,19 @@ public void executePlayerCommand(UUID playerId, String command) agentClient.executePlayerCommand(playerId, trimmedCommand); } + @Override + public List tabComplete(UUID playerId, String commandLine) + { + ensureOpen(); + Objects.requireNonNull(playerId, "playerId may not be null."); + Objects.requireNonNull(commandLine, "commandLine may not be null."); + // Deliberately NOT trimmed: a trailing space selects argument completion over command-name completion, + // and the agent strips at most one leading slash. Only a fully-blank buffer is rejected. + if (commandLine.isBlank()) + throw new IllegalArgumentException("commandLine may not be blank."); + return agentClient.tabComplete(playerId, commandLine); + } + @Override public void grantPermission(UUID playerId, String permission) { diff --git a/lightkeeper-framework-junit/src/main/java/nl/pim16aap2/lightkeeper/framework/internal/UdsAgentClient.java b/lightkeeper-framework-junit/src/main/java/nl/pim16aap2/lightkeeper/framework/internal/UdsAgentClient.java index 7be0c16c..d4d7acb4 100644 --- a/lightkeeper-framework-junit/src/main/java/nl/pim16aap2/lightkeeper/framework/internal/UdsAgentClient.java +++ b/lightkeeper-framework-junit/src/main/java/nl/pim16aap2/lightkeeper/framework/internal/UdsAgentClient.java @@ -45,6 +45,7 @@ import nl.pim16aap2.lightkeeper.protocol.RemovePlayer; import nl.pim16aap2.lightkeeper.protocol.RightClickBlock; import nl.pim16aap2.lightkeeper.protocol.SetBlock; +import nl.pim16aap2.lightkeeper.protocol.TabCompletePlayer; import nl.pim16aap2.lightkeeper.protocol.TeleportPlayer; import nl.pim16aap2.lightkeeper.protocol.UnloadChunk; import nl.pim16aap2.lightkeeper.protocol.UnregisterEventListener; @@ -239,6 +240,13 @@ void executePlayerCommand(UUID uuid, String command) send(cmd); } + List tabComplete(UUID uuid, String commandLine) + { + final TabCompletePlayer.Command command = + new TabCompletePlayer.Command(nextRequestId(), uuid, commandLine); + return send(command).completions(); + } + void mutatePlayerPermission(UUID uuid, String permission, MutatePlayerPermission.Mode mode) { final MutatePlayerPermission.Command command = diff --git a/lightkeeper-framework-junit/src/test/java/nl/pim16aap2/lightkeeper/framework/ChatComponentSnapshotTest.java b/lightkeeper-framework-junit/src/test/java/nl/pim16aap2/lightkeeper/framework/ChatComponentSnapshotTest.java new file mode 100644 index 00000000..f34c77a8 --- /dev/null +++ b/lightkeeper-framework-junit/src/test/java/nl/pim16aap2/lightkeeper/framework/ChatComponentSnapshotTest.java @@ -0,0 +1,113 @@ +package nl.pim16aap2.lightkeeper.framework; + +import org.junit.jupiter.api.Test; + +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; + +class ChatComponentSnapshotTest +{ + @Test + void clickRunCommand_shouldExtractCommandFromRootClickEvent() + { + // setup — the 1.21.5+ wire spells the field click_event (snake_case) + final ChatComponentSnapshot component = new ChatComponentSnapshot( + "{\"text\":\"click me\",\"click_event\":{\"action\":\"run_command\",\"command\":\"/aa confirm\"}}"); + + // execute + final Optional command = component.clickRunCommand(); + + // verify + assertThat(command).contains("/aa confirm"); + } + + @Test + void clickRunCommand_shouldMatchClickEventNestedInExtraChild() + { + // setup — NMS serializes clickable text inside an 'extra' child rather than the root object + final ChatComponentSnapshot component = new ChatComponentSnapshot( + "{\"text\":\"\",\"extra\":[{\"text\":\"click me\"," + + "\"click_event\":{\"action\":\"run_command\",\"command\":\"/lktestgui\"}}]}"); + + // execute + final Optional command = component.clickRunCommand(); + + // verify — a root-only lookup would miss this + assertThat(command).contains("/lktestgui"); + } + + @Test + void clickRunCommand_shouldReturnEmptyWhenComponentHasNoClickEvent() + { + // setup + final ChatComponentSnapshot component = new ChatComponentSnapshot("{\"text\":\"plain\"}"); + + // execute + verify + assertThat(component.clickRunCommand()).isEmpty(); + } + + @Test + void clickRunCommand_shouldReturnEmptyForSuggestCommandOnlyComponent() + { + // setup — per-action matching: a suggest_command click must not satisfy clickRunCommand() + final ChatComponentSnapshot component = new ChatComponentSnapshot( + "{\"text\":\"type\",\"click_event\":{\"action\":\"suggest_command\",\"command\":\"/aa create \"}}"); + + // execute + verify + assertThat(component.clickRunCommand()).isEmpty(); + } + + @Test + void clickRunCommand_shouldExtractLegacyCamelCaseClickEventValue() + { + // setup — pre-1.21.5 payloads used clickEvent with a 'value' field; extraction accepts them so it + // stays consistent with hasClickableChatText's lenient matching. + final ChatComponentSnapshot snapshot = new ChatComponentSnapshot( + "{\"text\":\"[Confirm]\",\"clickEvent\":{\"action\":\"run_command\",\"value\":\"/lk confirm\"}}"); + + // execute + final java.util.Optional result = snapshot.clickRunCommand(); + + // verify + assertThat(result).contains("/lk confirm"); + } + + @Test + void clickRunCommand_shouldReturnEmptyForMalformedJson() + { + // setup — malformed component JSON degrades to empty rather than throwing + final ChatComponentSnapshot component = new ChatComponentSnapshot("{\"text\":\"broken\" BROKEN"); + + // execute + verify + assertThat(component.clickRunCommand()).isEmpty(); + } + + @Test + void clickSuggestedCommand_shouldExtractCommandAndPreserveTrailingSpace() + { + // setup — suggest-command payloads often end with a space to prime the next argument + final ChatComponentSnapshot component = new ChatComponentSnapshot( + "{\"text\":\"type\",\"click_event\":{\"action\":\"suggest_command\",\"command\":\"/aa create \"}}"); + + // execute + final Optional command = component.clickSuggestedCommand(); + + // verify — the trailing space must survive intact + assertThat(command).contains("/aa create "); + } + + @Test + void clickRunCommand_shouldReturnItsOwnActionWhenRunAndSuggestCoexist() + { + // setup — a paginated control can carry both a run_command and a suggest_command run + final ChatComponentSnapshot component = new ChatComponentSnapshot( + "{\"text\":\"\",\"extra\":[" + + "{\"text\":\"[<]\",\"click_event\":{\"action\":\"suggest_command\",\"command\":\"/page \"}}," + + "{\"text\":\"[>]\",\"click_event\":{\"action\":\"run_command\",\"command\":\"/page 2\"}}]}"); + + // execute + verify — each accessor returns its own action's payload, never the other's + assertThat(component.clickRunCommand()).contains("/page 2"); + assertThat(component.clickSuggestedCommand()).contains("/page "); + } +} diff --git a/lightkeeper-framework-junit/src/test/java/nl/pim16aap2/lightkeeper/framework/PlayerHandleTest.java b/lightkeeper-framework-junit/src/test/java/nl/pim16aap2/lightkeeper/framework/PlayerHandleTest.java index 5f762839..507464fb 100644 --- a/lightkeeper-framework-junit/src/test/java/nl/pim16aap2/lightkeeper/framework/PlayerHandleTest.java +++ b/lightkeeper-framework-junit/src/test/java/nl/pim16aap2/lightkeeper/framework/PlayerHandleTest.java @@ -64,6 +64,49 @@ void executeCommand_shouldDelegateToGatewayAndReturnSelf() verify(frameworkGateway).executePlayerCommand(PLAYER_UUID, "time set day"); } + @Test + void tabComplete_shouldDelegateToGatewayAndReturnCompletions() + { + // setup + when(frameworkGateway.tabComplete(PLAYER_UUID, "/lktestg")) + .thenReturn(List.of("/lktestgui", "/lktestlocale")); + + // execute + final List completions = playerHandle.tabComplete("/lktestg"); + + // verify + assertThat(completions).containsExactly("/lktestgui", "/lktestlocale"); + verify(frameworkGateway).tabComplete(PLAYER_UUID, "/lktestg"); + } + + @Test + void clickChatComponent_shouldExtractRunCommandAndExecuteItAsThePlayer() + { + // setup — the modern 1.21.5+ wire spells the click field click_event (snake_case) + final ChatComponentSnapshot component = new ChatComponentSnapshot( + "{\"text\":\"Confirm\",\"click_event\":{\"action\":\"run_command\",\"command\":\"/aa confirm\"}}"); + + // execute + final PlayerHandle result = playerHandle.clickChatComponent(component); + + // verify + assertThat(result).isSameAs(playerHandle); + verify(frameworkGateway).executePlayerCommand(PLAYER_UUID, "/aa confirm"); + } + + @Test + void clickChatComponent_shouldThrowWhenComponentHasNoRunCommandClick() + { + // setup — a suggest_command click is not a run_command click and must be rejected + final ChatComponentSnapshot component = new ChatComponentSnapshot( + "{\"text\":\"Type\",\"click_event\":{\"action\":\"suggest_command\",\"command\":\"/aa create \"}}"); + + // execute + verify + assertThatThrownBy(() -> playerHandle.clickChatComponent(component)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("run_command"); + } + @Test void teleport_shouldDelegateToGatewayAndReturnSelf() { diff --git a/lightkeeper-framework-junit/src/test/java/nl/pim16aap2/lightkeeper/framework/assertions/HandleAssertionsTest.java b/lightkeeper-framework-junit/src/test/java/nl/pim16aap2/lightkeeper/framework/assertions/HandleAssertionsTest.java index 4a02f748..446b7223 100644 --- a/lightkeeper-framework-junit/src/test/java/nl/pim16aap2/lightkeeper/framework/assertions/HandleAssertionsTest.java +++ b/lightkeeper-framework-junit/src/test/java/nl/pim16aap2/lightkeeper/framework/assertions/HandleAssertionsTest.java @@ -78,6 +78,20 @@ void playerHandleAssert_shouldValidateInventoryAndClickableChatText() .hasClickableChatText("Open"); } + @Test + void playerHandleAssert_shouldMatchModernSnakeCaseClickEvent() + { + // setup — the 1.21.5+ component codec spells the field click_event (snake_case); keying only on the + // pre-1.21.5 clickEvent (as the assertion used to) never matched real 1.21.11 wire JSON + final PlayerHandle handle = mock(PlayerHandle.class); + when(handle.name()).thenReturn("bot"); + when(handle.chatComponents()).thenReturn(List.of(new ChatComponentSnapshot( + "{\"text\":\"Open\",\"click_event\":{\"action\":\"run_command\",\"command\":\"/aa\"}}"))); + + // execute + verify + LightkeeperAssertions.assertThat(handle).hasClickableChatText("Open"); + } + @Test void playerHandleAssert_shouldFailWhenUnexpectedItemExists() { diff --git a/lightkeeper-framework-junit/src/test/java/nl/pim16aap2/lightkeeper/framework/internal/DefaultLightkeeperFrameworkGatewayTest.java b/lightkeeper-framework-junit/src/test/java/nl/pim16aap2/lightkeeper/framework/internal/DefaultLightkeeperFrameworkGatewayTest.java index e6f6f5e0..3c164d62 100644 --- a/lightkeeper-framework-junit/src/test/java/nl/pim16aap2/lightkeeper/framework/internal/DefaultLightkeeperFrameworkGatewayTest.java +++ b/lightkeeper-framework-junit/src/test/java/nl/pim16aap2/lightkeeper/framework/internal/DefaultLightkeeperFrameworkGatewayTest.java @@ -89,6 +89,83 @@ void unsetPermission_shouldDelegateToAgentClientWithUnsetMode() verify(agentClient).mutatePlayerPermission(playerId, "lightkeeper.fly", MutatePlayerPermission.Mode.UNSET); } + @Test + void tabComplete_shouldDelegateToAgentClientAndReturnCompletions() + { + // setup + final UdsAgentClient agentClient = mock(UdsAgentClient.class); + final UUID playerId = UUID.randomUUID(); + when(agentClient.tabComplete(playerId, "/lktestg")).thenReturn(List.of("/lktestgui")); + final DefaultLightkeeperFramework framework = new DefaultLightkeeperFramework( + runtimeManifest(), + mock(MinecraftServerProcess.class), + agentClient, + new PlayerScopeRegistry() + ); + + // execute + final List completions = framework.tabComplete(playerId, "/lktestg"); + + // verify + assertThat(completions).containsExactly("/lktestgui"); + verify(agentClient).tabComplete(playerId, "/lktestg"); + } + + @Test + void tabComplete_shouldPassBufferThroughUnmodifiedPreservingTrailingSpace() + { + // setup + final UdsAgentClient agentClient = mock(UdsAgentClient.class); + final UUID playerId = UUID.randomUUID(); + when(agentClient.tabComplete(playerId, "/gamemode ")).thenReturn(List.of()); + final DefaultLightkeeperFramework framework = new DefaultLightkeeperFramework( + runtimeManifest(), + mock(MinecraftServerProcess.class), + agentClient, + new PlayerScopeRegistry() + ); + + // execute — a trailing space (argument completion) is never trimmed + framework.tabComplete(playerId, "/gamemode "); + + // verify + verify(agentClient).tabComplete(playerId, "/gamemode "); + } + + @Test + void tabComplete_shouldThrowExceptionWhenCommandLineIsBlank() + { + // setup + final DefaultLightkeeperFramework framework = new DefaultLightkeeperFramework( + runtimeManifest(), + mock(MinecraftServerProcess.class), + mock(UdsAgentClient.class), + new PlayerScopeRegistry() + ); + + // execute + verify + assertThatThrownBy(() -> framework.tabComplete(UUID.randomUUID(), " ")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("blank"); + } + + @Test + @SuppressWarnings("NullAway") // Intentionally crosses the non-null API boundary to verify fail-fast validation. + void tabComplete_shouldThrowNullPointerExceptionWhenCommandLineIsNull() + { + // setup + final DefaultLightkeeperFramework framework = new DefaultLightkeeperFramework( + runtimeManifest(), + mock(MinecraftServerProcess.class), + mock(UdsAgentClient.class), + new PlayerScopeRegistry() + ); + + // execute + verify + assertThatThrownBy(() -> framework.tabComplete(UUID.randomUUID(), null)) + .isInstanceOf(NullPointerException.class); + } + @Test void grantPermission_shouldThrowExceptionWhenPermissionIsBlank() { diff --git a/lightkeeper-framework-junit/src/test/java/nl/pim16aap2/lightkeeper/framework/internal/UdsAgentClientTest.java b/lightkeeper-framework-junit/src/test/java/nl/pim16aap2/lightkeeper/framework/internal/UdsAgentClientTest.java index 277f3f2b..2d8e2e57 100644 --- a/lightkeeper-framework-junit/src/test/java/nl/pim16aap2/lightkeeper/framework/internal/UdsAgentClientTest.java +++ b/lightkeeper-framework-junit/src/test/java/nl/pim16aap2/lightkeeper/framework/internal/UdsAgentClientTest.java @@ -727,6 +727,28 @@ void playerChatComponents_shouldParseComponentSnapshots(@TempDir Path tempDirect } } + @Test + void tabComplete_shouldReturnCompletionsFromResponseAndPreserveBuffer(@TempDir Path tempDirectory) + throws Exception + { + // setup + final Path socketPath = tempDirectory.resolve("tab-complete.sock"); + final String responseJson = + "{\"requestId\":\"1\",\"success\":true,\"completions\":[\"/lktestgui\",\"/lktestlocale\"]}"; + try (AgentSocketServer server = AgentSocketServer.start(socketPath, responseJson); + UdsAgentClient client = new UdsAgentClient(socketPath, Duration.ofSeconds(3))) + { + // execute — the trailing slash and space of the buffer must reach the wire unmodified + final List completions = client.tabComplete(PLAYER_ID, "/lktestg "); + + // verify + assertThat(completions).containsExactly("/lktestgui", "/lktestlocale"); + assertThat(server.capturedRequest()) + .contains("\"action\":\"TAB_COMPLETE_PLAYER\"") + .contains("\"commandLine\":\"/lktestg \""); + } + } + @Test void mutatePlayerPermission_shouldSendGrantModeInRequest(@TempDir Path tempDirectory) throws Exception diff --git a/lightkeeper-integration-tests/src/test/java/nl/pim16aap2/lightkeeper/maven/test/LightkeeperChatClickIT.java b/lightkeeper-integration-tests/src/test/java/nl/pim16aap2/lightkeeper/maven/test/LightkeeperChatClickIT.java new file mode 100644 index 00000000..43e517f1 --- /dev/null +++ b/lightkeeper-integration-tests/src/test/java/nl/pim16aap2/lightkeeper/maven/test/LightkeeperChatClickIT.java @@ -0,0 +1,52 @@ +package nl.pim16aap2.lightkeeper.maven.test; + +import nl.pim16aap2.lightkeeper.framework.ChatComponentSnapshot; +import nl.pim16aap2.lightkeeper.framework.ILightkeeperFramework; +import nl.pim16aap2.lightkeeper.framework.LightkeeperExtension; +import nl.pim16aap2.lightkeeper.framework.PlayerHandle; +import nl.pim16aap2.lightkeeper.framework.WorldHandle; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; + +import java.time.Duration; +import java.util.Optional; + +import static nl.pim16aap2.lightkeeper.framework.assertions.LightkeeperAssertions.assertThat; +import static nl.pim16aap2.lightkeeper.framework.assertions.LightkeeperAssertions.eventually; + +@ExtendWith(LightkeeperExtension.class) +class LightkeeperChatClickIT +{ + @Test + void chatClick_shouldCaptureExtractAndDispatchRunCommandComponent(ILightkeeperFramework framework) + { + // setup + final WorldHandle world = framework.worlds().main(); + final PlayerHandle player = framework.bots().join("lkclick01", world); + + // execute — the test plugin sends the bot a clickable run_command chat component + player.executeCommand("lktestclick"); + framework.waitUntil( + () -> player.chatComponents().stream().anyMatch(component -> component.clickRunCommand().isPresent()), + Duration.ofSeconds(15)); + + final ChatComponentSnapshot clickable = player.chatComponents().stream() + .filter(component -> component.clickRunCommand().isPresent()) + .reduce((first, second) -> second) + .orElseThrow(); + + // verify — the native codec spells the field click_event (snake_case), never the stale clickEvent + assertThat(clickable.json()) + .contains("\"click_event\"") + .doesNotContain("\"clickEvent\""); + final Optional extracted = clickable.clickRunCommand(); + assertThat(extracted).contains("/lktestclick confirm"); + + // execute — clicking the component dispatches its run_command as the player + player.clickChatComponent(clickable); + + // verify — the dispatched command produced its observable confirmation message + eventually(Duration.ofSeconds(15), () -> + assertThat(player).receivedMessagesText().contains("LK_TEST_CLICK confirmed")); + } +} diff --git a/lightkeeper-integration-tests/src/test/java/nl/pim16aap2/lightkeeper/maven/test/LightkeeperTabCompleteIT.java b/lightkeeper-integration-tests/src/test/java/nl/pim16aap2/lightkeeper/maven/test/LightkeeperTabCompleteIT.java new file mode 100644 index 00000000..1c611b72 --- /dev/null +++ b/lightkeeper-integration-tests/src/test/java/nl/pim16aap2/lightkeeper/maven/test/LightkeeperTabCompleteIT.java @@ -0,0 +1,64 @@ +package nl.pim16aap2.lightkeeper.maven.test; + +import nl.pim16aap2.lightkeeper.framework.ILightkeeperFramework; +import nl.pim16aap2.lightkeeper.framework.LightkeeperExtension; +import nl.pim16aap2.lightkeeper.framework.PlayerHandle; +import nl.pim16aap2.lightkeeper.framework.WorldHandle; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; + +import java.util.List; + +import static nl.pim16aap2.lightkeeper.framework.assertions.LightkeeperAssertions.assertThat; + +@ExtendWith(LightkeeperExtension.class) +class LightkeeperTabCompleteIT +{ + private static final String TAB_PERMISSION = "lightkeeper.testplugin.lktestperm"; + + @Test + void tabComplete_shouldSuggestPermissionlessPluginCommandName(ILightkeeperFramework framework) + { + // setup + final WorldHandle world = framework.worlds().main(); + final PlayerHandle player = framework.bots().join("lktab001", world); + + // execute — lktestlocale declares no permission, so it is visible to any player + final List completions = player.tabComplete("/lktestlocal"); + + // verify — a player sender's completions carry a leading slash, so match on the substring + assertThat(completions).anyMatch(completion -> completion.contains("lktestlocale")); + } + + @Test + void tabComplete_shouldReturnEmptyListForUnknownCommand(ILightkeeperFramework framework) + { + // setup + final WorldHandle world = framework.worlds().main(); + final PlayerHandle player = framework.bots().join("lktab002", world); + + // execute — the trailing token triggers the args-completion branch, whose unknown-command lookup is null + final List completions = player.tabComplete("/zzznocommand arg"); + + // verify — a null command-map result is normalized to an empty list rather than propagating + assertThat(completions).isEmpty(); + } + + @Test + void tabComplete_shouldFilterCommandNamesByPlayerPermission(ILightkeeperFramework framework) + { + // setup — lktestperm carries a command-level permission (lightkeeper.testplugin.lktestperm) that + // defaults to false, so the command map filters it out of completions until the player is granted it + final WorldHandle world = framework.worlds().main(); + final PlayerHandle player = framework.bots().join("lktab003", world); + + // execute + verify — without the permission the command is filtered out of the completions + final List beforeGrant = player.tabComplete("/lktestp"); + assertThat(beforeGrant).noneMatch(completion -> completion.contains("lktestperm")); + + // execute + verify — granting the permission makes the same buffer offer the command + player.permissions().grant(TAB_PERMISSION); + final List afterGrant = player.tabComplete("/lktestp"); + assertThat(afterGrant).anyMatch(completion -> completion.contains("lktestperm")); + } +} diff --git a/lightkeeper-nms-parent/lightkeeper-nms-v1_21_R7/src/main/java/nl/pim16aap2/lightkeeper/nms/v121r7/BotPlayerNmsAdapterV1_21_R7.java b/lightkeeper-nms-parent/lightkeeper-nms-v1_21_R7/src/main/java/nl/pim16aap2/lightkeeper/nms/v121r7/BotPlayerNmsAdapterV1_21_R7.java index de6faa45..f0c56eee 100644 --- a/lightkeeper-nms-parent/lightkeeper-nms-v1_21_R7/src/main/java/nl/pim16aap2/lightkeeper/nms/v121r7/BotPlayerNmsAdapterV1_21_R7.java +++ b/lightkeeper-nms-parent/lightkeeper-nms-v1_21_R7/src/main/java/nl/pim16aap2/lightkeeper/nms/v121r7/BotPlayerNmsAdapterV1_21_R7.java @@ -645,6 +645,20 @@ private static boolean isSafeComponentAccessor(Method method) if (returnType == Void.TYPE || returnType.isPrimitive()) return false; + // A zero-arg accessor that directly returns a chat-component type is always followed, regardless of its + // name. Spigot's remapped record accessors are single obfuscated letters (e.g. IChatBaseComponent b() on + // ClientboundSystemChatPacket), which no name heuristic matches, whereas Paper exposes the same field as + // content(); keying on the return type rather than the method name captures the component on both distros. + // Deliberately ONLY the type-name suffix: a whole-chat-package match would also follow MessageSignature/ + // ChatType.Bound/FilterMask accessors on multi-field chat packets and could nondeterministically capture a + // decoration instead of the message. Note this widens capture to any Component-returning accessor + // (boss bars, titles, tab-list headers) — component capture is type-driven, not name-driven. + final String returnTypeName = returnType.getName(); + if (returnTypeName.endsWith("Component")) + return true; + + // For container return types the component sits one level deeper, so require a component-like accessor + // name before following the getter to avoid walking unrelated object graphs. final String lowerMethodName = methodName.toLowerCase(Locale.ROOT); final boolean safeName = methodName.startsWith("get") || methodName.startsWith("is") || @@ -655,16 +669,9 @@ private static boolean isSafeComponentAccessor(Method method) if (!safeName) return false; - if (Optional.class.isAssignableFrom(returnType)) - return true; - if (Collection.class.isAssignableFrom(returnType)) - return true; - if (returnType.isArray()) - return true; - - final String returnTypeName = returnType.getName(); - return returnTypeName.startsWith("net.minecraft.network.chat.") || - returnTypeName.endsWith("Component"); + return Optional.class.isAssignableFrom(returnType) || + Collection.class.isAssignableFrom(returnType) || + returnType.isArray(); } private static @Nullable String extractText( diff --git a/lightkeeper-nms-parent/lightkeeper-nms-v1_21_R7/src/test/java/nl/pim16aap2/lightkeeper/nms/v121r7/BotPlayerNmsAdapterV1_21_R7Test.java b/lightkeeper-nms-parent/lightkeeper-nms-v1_21_R7/src/test/java/nl/pim16aap2/lightkeeper/nms/v121r7/BotPlayerNmsAdapterV1_21_R7Test.java index ea2d6438..ad32015a 100644 --- a/lightkeeper-nms-parent/lightkeeper-nms-v1_21_R7/src/test/java/nl/pim16aap2/lightkeeper/nms/v121r7/BotPlayerNmsAdapterV1_21_R7Test.java +++ b/lightkeeper-nms-parent/lightkeeper-nms-v1_21_R7/src/test/java/nl/pim16aap2/lightkeeper/nms/v121r7/BotPlayerNmsAdapterV1_21_R7Test.java @@ -406,6 +406,43 @@ void extractComponentJson_shouldInspectOnlySafeAccessors() assertThat(unsafeComponentAccessor).isFalse(); } + @Test + void extractComponentJson_shouldFollowObfuscatedRecordAccessorReturningComponentType() + throws Exception + { + // setup — Spigot's remapped ClientboundSystemChatPacket exposes its component through an obfuscated + // single-letter record accessor (e.g. IChatBaseComponent b()) that no method-name heuristic matches; + // keying on the return type follows it, so components are captured on Spigot as well as on Paper. + final BotPlayerNmsAdapterV1_21_R7 adapter = allocateAdapter(); + setField(adapter, "componentJsonCodec", new FakeCodec()); + setField(adapter, "componentJsonOps", new Object()); + setField(adapter, "componentCodecEncodeStartMethod", FakeCodec.class.getMethod( + "encodeStart", + Object.class, + Object.class + )); + setField(adapter, "dataResultResultMethod", FakeDataResult.class.getMethod("result")); + final boolean obfuscatedAccessorSafe = (boolean) invokeStatic( + "isSafeComponentAccessor", + new Class[]{Method.class}, + ObfuscatedComponentPacket.class.getMethod("b") + ); + + // execute + final String extracted = (String) invokeInstance( + adapter, + "extractComponentJson", + new Class[]{Object.class, int.class, IdentityHashMap.class}, + new ObfuscatedComponentPacket(new FakeComponent("hi")), + 4, + new IdentityHashMap<>() + ); + + // verify + assertThat(obfuscatedAccessorSafe).isTrue(); + assertThat(extracted).isEqualTo("{\"text\":\"hi\"}"); + } + @Test void extractComponentJson_shouldReturnNullWhenComponentSerializationFails() throws Exception @@ -735,7 +772,11 @@ public FakeComponent getComponent() } } - public record UnsafeComponentPacket(FakeComponent value) + public record UnsafeComponentPacket(String value) + { + } + + public record ObfuscatedComponentPacket(FakeComponent b) { } diff --git a/lightkeeper-protocol/src/main/java/nl/pim16aap2/lightkeeper/protocol/IAgentCommand.java b/lightkeeper-protocol/src/main/java/nl/pim16aap2/lightkeeper/protocol/IAgentCommand.java index 2787fe8b..15eb0102 100644 --- a/lightkeeper-protocol/src/main/java/nl/pim16aap2/lightkeeper/protocol/IAgentCommand.java +++ b/lightkeeper-protocol/src/main/java/nl/pim16aap2/lightkeeper/protocol/IAgentCommand.java @@ -58,6 +58,7 @@ @JsonSubTypes.Type(value = CancelNextEvents.Command.class, name = "CANCEL_NEXT_EVENTS"), @JsonSubTypes.Type(value = PlayerChat.Command.class, name = "PLAYER_CHAT"), @JsonSubTypes.Type(value = QueryEntities.Command.class, name = "QUERY_ENTITIES"), + @JsonSubTypes.Type(value = TabCompletePlayer.Command.class, name = "TAB_COMPLETE_PLAYER"), }) public sealed interface IAgentCommand permits Handshake.Command, MainWorld.Command, NewWorld.Command, ExecuteCommand.Command, @@ -72,7 +73,8 @@ public sealed interface IAgentCommand GetPlayerChatComponents.Command, GetServerPlatform.Command, GetServerErrors.Command, ClearServerErrors.Command, MutatePlayerPermission.Command, HasPlayerPermission.Command, - CancelNextEvents.Command, PlayerChat.Command, QueryEntities.Command + CancelNextEvents.Command, PlayerChat.Command, QueryEntities.Command, + TabCompletePlayer.Command { /** * Correlation identifier matching the response's {@code requestId}. diff --git a/lightkeeper-protocol/src/main/java/nl/pim16aap2/lightkeeper/protocol/IAgentResponse.java b/lightkeeper-protocol/src/main/java/nl/pim16aap2/lightkeeper/protocol/IAgentResponse.java index f6f1e113..bc556084 100644 --- a/lightkeeper-protocol/src/main/java/nl/pim16aap2/lightkeeper/protocol/IAgentResponse.java +++ b/lightkeeper-protocol/src/main/java/nl/pim16aap2/lightkeeper/protocol/IAgentResponse.java @@ -46,6 +46,7 @@ public sealed interface IAgentResponse RemovePlayer.Response, RightClickBlock.Response, SetBlock.Response, + TabCompletePlayer.Response, TeleportPlayer.Response, UnloadChunk.Response, UnregisterEventListener.Response, diff --git a/lightkeeper-protocol/src/main/java/nl/pim16aap2/lightkeeper/protocol/TabCompletePlayer.java b/lightkeeper-protocol/src/main/java/nl/pim16aap2/lightkeeper/protocol/TabCompletePlayer.java new file mode 100644 index 00000000..403060c9 --- /dev/null +++ b/lightkeeper-protocol/src/main/java/nl/pim16aap2/lightkeeper/protocol/TabCompletePlayer.java @@ -0,0 +1,78 @@ +package nl.pim16aap2.lightkeeper.protocol; + +import java.util.List; +import java.util.UUID; + +/** + * Computes command tab-completions as the given synthetic player. + * + *

The completions are produced by the server's live {@code CommandMap#tabComplete} call, so they are + * permission-filtered for the requesting player exactly as a real client tab-complete would be. + */ +public final class TabCompletePlayer +{ + private TabCompletePlayer() + { + } + + /** + * Command record for {@code TAB_COMPLETE_PLAYER}. + * + * @param requestId + * Correlation identifier matching the response's {@code requestId}. + * @param uuid + * Unique identifier of the player whose permissions and context drive the completion. + * @param commandLine + * The raw command-line buffer to complete, optionally beginning with a leading slash. Surrounding + * whitespace is not stripped: a trailing space is semantically load-bearing here (for example + * {@code "gamemode "} completes the first argument, whereas {@code "gamemode"} completes the command + * name itself), so the buffer is preserved verbatim. + */ + public record Command( + String requestId, + UUID uuid, + String commandLine + ) implements IAgentCommand + { + /** + * Validates the command inputs. + * + *

Unlike {@link ExecutePlayerCommand.Command}, the {@code commandLine} is intentionally not rejected + * when blank and not stripped: a whitespace-only or trailing-whitespace buffer is a valid, + * meaningfully-different tab-complete input at the wire level; the framework's public API rejects blank + * input before it reaches this command. + */ + public Command + { + ProtocolPreconditions.requireNonBlank(requestId, "requestId"); + ProtocolPreconditions.requireNonNull(uuid, "uuid"); + ProtocolPreconditions.requireNonNull(commandLine, "commandLine"); + } + + @Override + public Class responseType() + { + return Response.class; + } + } + + /** + * Response record for {@code TAB_COMPLETE_PLAYER}. + * + * @param completions + * The tab-completion suggestions in server order. A {@code null} {@code CommandMap#tabComplete} result + * (unknown command, or a {@code TabCompleter} returning {@code null}) is normalized to an empty list. + */ + public record Response( + List completions + ) implements IAgentResponse + { + /** + * Defensively copies the completion list, defaulting a {@code null} to an empty list. + */ + public Response + { + completions = completions == null ? List.of() : List.copyOf(completions); + } + } +} diff --git a/lightkeeper-protocol/src/test/java/nl/pim16aap2/lightkeeper/protocol/TabCompletePlayerTest.java b/lightkeeper-protocol/src/test/java/nl/pim16aap2/lightkeeper/protocol/TabCompletePlayerTest.java new file mode 100644 index 00000000..de4c8bca --- /dev/null +++ b/lightkeeper-protocol/src/test/java/nl/pim16aap2/lightkeeper/protocol/TabCompletePlayerTest.java @@ -0,0 +1,137 @@ +package nl.pim16aap2.lightkeeper.protocol; + +import tools.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class TabCompletePlayerTest +{ + @Test + void command_shouldRejectBlankRequestId() + { + // setup + final UUID uuid = UUID.randomUUID(); + + // execute + verify + assertThatThrownBy(() -> new TabCompletePlayer.Command(" ", uuid, "gamemode")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("requestId"); + } + + @Test + @SuppressWarnings("NullAway") // Intentionally crosses the non-null API boundary to verify fail-fast validation. + void command_shouldRejectNullUuid() + { + // execute + verify + assertThatThrownBy(() -> new TabCompletePlayer.Command("request-1", null, "gamemode")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("uuid"); + } + + @Test + @SuppressWarnings("NullAway") // Intentionally crosses the non-null API boundary to verify fail-fast validation. + void command_shouldRejectNullCommandLine() + { + // execute + verify + assertThatThrownBy(() -> new TabCompletePlayer.Command("request-1", UUID.randomUUID(), null)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("commandLine"); + } + + @Test + void command_shouldPreserveTrailingWhitespaceWithoutStripping() + { + // setup + final UUID uuid = UUID.randomUUID(); + + // execute — a trailing space selects argument completion and is semantically load-bearing + final TabCompletePlayer.Command command = new TabCompletePlayer.Command("request-1", uuid, "/gamemode "); + + // verify + assertThat(command.commandLine()).isEqualTo("/gamemode "); + } + + @Test + void command_shouldAllowBlankCommandLine() + { + // setup + final UUID uuid = UUID.randomUUID(); + + // execute — unlike ExecutePlayerCommand, a whitespace-only buffer is a valid tab-complete input + final TabCompletePlayer.Command command = new TabCompletePlayer.Command("request-1", uuid, " "); + + // verify + assertThat(command.commandLine()).isEqualTo(" "); + } + + @Test + void response_shouldDefensivelyCopyCompletionsSoLaterMutationsDoNotLeak() + { + // setup + final List source = new ArrayList<>(List.of("help")); + final TabCompletePlayer.Response response = new TabCompletePlayer.Response(source); + + // execute + source.add("plugins"); + + // verify + assertThat(response.completions()).containsExactly("help"); + assertThatThrownBy(() -> response.completions().add("me")) + .isInstanceOf(UnsupportedOperationException.class); + } + + @Test + @SuppressWarnings("NullAway") // Intentionally crosses the non-null API boundary to verify default-on-null. + void response_shouldDefaultNullCompletionsToEmptyList() + { + // setup + execute + final TabCompletePlayer.Response response = new TabCompletePlayer.Response(null); + + // verify + assertThat(response.completions()).isEmpty(); + } + + @Test + void command_shouldRoundTripThroughJson() + throws Exception + { + // setup + final ObjectMapper mapper = AgentProtocolMapper.create(); + final UUID uuid = UUID.fromString("00000000-0000-0000-0000-0000000000cc"); + final TabCompletePlayer.Command original = new TabCompletePlayer.Command("req-tc", uuid, "/lktestg"); + + // execute + final String json = mapper.writeValueAsString(original); + @SuppressWarnings("rawtypes") + final IAgentCommand deserialized = mapper.readValue(json, IAgentCommand.class); + + // verify + assertThat(deserialized).isInstanceOf(TabCompletePlayer.Command.class); + final TabCompletePlayer.Command result = (TabCompletePlayer.Command) deserialized; + assertThat(result.requestId()).isEqualTo("req-tc"); + assertThat(result.uuid()).isEqualTo(uuid); + assertThat(result.commandLine()).isEqualTo("/lktestg"); + } + + @Test + void response_shouldRoundTripThroughJson() + throws Exception + { + // setup + final ObjectMapper mapper = AgentProtocolMapper.create(); + final TabCompletePlayer.Response original = new TabCompletePlayer.Response(List.of("lktestgui", "lktestlocale")); + + // execute + final String json = mapper.writeValueAsString(original); + final TabCompletePlayer.Response result = mapper.readValue(json, TabCompletePlayer.Response.class); + + // verify + assertThat(result.completions()).containsExactly("lktestgui", "lktestlocale"); + } +} diff --git a/lightkeeper-runtime-core/src/main/java/nl/pim16aap2/lightkeeper/runtime/RuntimeProtocol.java b/lightkeeper-runtime-core/src/main/java/nl/pim16aap2/lightkeeper/runtime/RuntimeProtocol.java index 542e48eb..6830c7c8 100644 --- a/lightkeeper-runtime-core/src/main/java/nl/pim16aap2/lightkeeper/runtime/RuntimeProtocol.java +++ b/lightkeeper-runtime-core/src/main/java/nl/pim16aap2/lightkeeper/runtime/RuntimeProtocol.java @@ -20,7 +20,7 @@ public final class RuntimeProtocol * in a backward-incompatible way. Both the framework and the agent must agree on this value; * a mismatch causes an {@code HANDSHAKE} failure. */ - public static final int VERSION = 10; + public static final int VERSION = 11; /** * Minecraft server version supported by this LightKeeper build. diff --git a/lightkeeper-spigot-test-plugin/src/main/java/nl/pim16aap2/lightkeeper/spigot/testplugin/LightkeeperSpigotTestPlugin.java b/lightkeeper-spigot-test-plugin/src/main/java/nl/pim16aap2/lightkeeper/spigot/testplugin/LightkeeperSpigotTestPlugin.java index bc4e43a3..075fac2d 100644 --- a/lightkeeper-spigot-test-plugin/src/main/java/nl/pim16aap2/lightkeeper/spigot/testplugin/LightkeeperSpigotTestPlugin.java +++ b/lightkeeper-spigot-test-plugin/src/main/java/nl/pim16aap2/lightkeeper/spigot/testplugin/LightkeeperSpigotTestPlugin.java @@ -36,6 +36,10 @@ public final class LightkeeperSpigotTestPlugin extends JavaPlugin implements Lis * Command name used to log the invoking player's client locale for full-login locale integration tests. */ private static final String LOCALE_COMMAND_NAME = "lktestlocale"; + /** + * Command name used to emit a clickable {@code run_command} chat component for chat-click integration tests. + */ + private static final String CLICK_COMMAND_NAME = "lktestclick"; /** * Prefix used for deterministic block interaction messages. */ @@ -66,9 +70,15 @@ public void onEnable() throw new IllegalStateException( "Required command '/%s' is not declared in plugin metadata.".formatted(LOCALE_COMMAND_NAME)); + final PluginCommand clickCommand = getCommand(CLICK_COMMAND_NAME); + if (clickCommand == null) + throw new IllegalStateException( + "Required command '/%s' is not declared in plugin metadata.".formatted(CLICK_COMMAND_NAME)); + pluginCommand.setExecutor(new LkTestGuiCommandExecutor(guiMenuService)); errorCommand.setExecutor(new LkTestErrorCommandExecutor(getLogger())); localeCommand.setExecutor(new LkTestLocaleCommandExecutor(getLogger())); + clickCommand.setExecutor(new LkTestClickCommandExecutor()); Bukkit.getPluginManager().registerEvents(this, this); } diff --git a/lightkeeper-spigot-test-plugin/src/main/java/nl/pim16aap2/lightkeeper/spigot/testplugin/LkTestClickCommandExecutor.java b/lightkeeper-spigot-test-plugin/src/main/java/nl/pim16aap2/lightkeeper/spigot/testplugin/LkTestClickCommandExecutor.java new file mode 100644 index 00000000..4f8d0f04 --- /dev/null +++ b/lightkeeper-spigot-test-plugin/src/main/java/nl/pim16aap2/lightkeeper/spigot/testplugin/LkTestClickCommandExecutor.java @@ -0,0 +1,80 @@ +package nl.pim16aap2.lightkeeper.spigot.testplugin; + +import org.bukkit.Bukkit; +import org.bukkit.command.Command; +import org.bukkit.command.CommandExecutor; +import org.bukkit.command.CommandSender; +import org.bukkit.entity.Player; + +/** + * Handles {@code /lktestclick} command execution for the standalone test plugin. + * + *

Invoked with no arguments, it sends the invoking player a chat component carrying a {@code run_command} + * click event, so integration tests can capture the component, extract the command, and "click" it. Invoked with + * {@code confirm} (the command the click event runs), it replies with a deterministic marker the test can assert + * on to prove the click actually dispatched the command. + * + *

The clickable component is produced through the vanilla {@code /tellraw} command rather than an API builder: + * this Minecraft version's spigot-api ships no chat-component builder (the BungeeCord chat API was removed and + * Adventure is Paper-only), and {@code /tellraw} is parsed by the same native component codec that serializes the + * {@code click_event} field on the wire, so the round trip is faithful and identical on Spigot and Paper. + */ +final class LkTestClickCommandExecutor implements CommandExecutor +{ + /** + * Visible text of the clickable component; integration tests locate the component by this marker. + */ + static final String CLICK_TEXT = "LK_CLICK_ME"; + /** + * Argument that triggers the confirmation reply instead of emitting a new clickable component. + */ + static final String CONFIRM_ARG = "confirm"; + /** + * Command the clickable component runs when clicked (a self-invocation with {@link #CONFIRM_ARG}). + */ + static final String RUN_COMMAND = "/lktestclick " + CONFIRM_ARG; + /** + * Deterministic reply sent once the {@link #RUN_COMMAND} is executed, asserted on by integration tests. + */ + static final String CONFIRM_MESSAGE = "LK_TEST_CLICK confirmed"; + /** + * Reply sent when the command is invoked by a non-player sender (only players receive chat components). + */ + static final String PLAYERS_ONLY_MESSAGE = "Only players can use /lktestclick."; + + /** + * Emits a clickable component to the player, or replies with the confirmation marker on {@code confirm}. + * + * @param sender + * Sender that invoked the command. + * @param command + * Command metadata. + * @param label + * Used command label. + * @param args + * Command arguments; {@code confirm} selects the confirmation reply. + * @return + * Always {@code true} because this executor handles all command outcomes directly. + */ + @Override + public boolean onCommand(CommandSender sender, Command command, String label, String[] args) + { + if (!(sender instanceof Player player)) + { + sender.sendMessage(PLAYERS_ONLY_MESSAGE); + return true; + } + + if (args.length > 0 && args[0].equalsIgnoreCase(CONFIRM_ARG)) + { + player.sendMessage(CONFIRM_MESSAGE); + return true; + } + + final String tellraw = ("minecraft:tellraw %s " + + "{\"text\":\"%s\",\"click_event\":{\"action\":\"run_command\",\"command\":\"%s\"}}") + .formatted(player.getName(), CLICK_TEXT, RUN_COMMAND); + Bukkit.dispatchCommand(Bukkit.getConsoleSender(), tellraw); + return true; + } +} diff --git a/lightkeeper-spigot-test-plugin/src/main/resources/plugin.yml b/lightkeeper-spigot-test-plugin/src/main/resources/plugin.yml index d79171b1..60672c49 100644 --- a/lightkeeper-spigot-test-plugin/src/main/resources/plugin.yml +++ b/lightkeeper-spigot-test-plugin/src/main/resources/plugin.yml @@ -15,6 +15,13 @@ commands: lktestlocale: description: Logs the invoking player's client locale for LightKeeper full-login locale integration tests usage: /lktestlocale + lktestclick: + description: Sends a clickable run_command chat component for LightKeeper chat-click integration tests + usage: /lktestclick + lktestperm: + description: A permission-gated no-op command for LightKeeper tab-completion permission integration tests + usage: /lktestperm + permission: lightkeeper.testplugin.lktestperm permissions: lightkeeper.testplugin.lktestgui: description: Allows opening the LightKeeper test GUI. @@ -22,3 +29,6 @@ permissions: lightkeeper.testplugin.lktesterror: description: Allows emitting LightKeeper error-capture test log events. default: false + lightkeeper.testplugin.lktestperm: + description: Gates visibility of /lktestperm in tab-completion. + default: false diff --git a/lightkeeper-spigot-test-plugin/src/test/java/nl/pim16aap2/lightkeeper/spigot/testplugin/LkTestClickCommandExecutorTest.java b/lightkeeper-spigot-test-plugin/src/test/java/nl/pim16aap2/lightkeeper/spigot/testplugin/LkTestClickCommandExecutorTest.java new file mode 100644 index 00000000..1fafd0f6 --- /dev/null +++ b/lightkeeper-spigot-test-plugin/src/test/java/nl/pim16aap2/lightkeeper/spigot/testplugin/LkTestClickCommandExecutorTest.java @@ -0,0 +1,81 @@ +package nl.pim16aap2.lightkeeper.spigot.testplugin; + +import org.bukkit.Bukkit; +import org.bukkit.command.Command; +import org.bukkit.command.CommandSender; +import org.bukkit.command.ConsoleCommandSender; +import org.bukkit.entity.Player; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.MockedStatic; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class LkTestClickCommandExecutorTest +{ + @Test + void onCommand_shouldSendConfirmMessageWhenArgIsConfirm() + { + // setup + final LkTestClickCommandExecutor executor = new LkTestClickCommandExecutor(); + final Player player = mock(); + final Command command = mock(); + + // execute + final boolean handled = executor.onCommand(player, command, "lktestclick", new String[]{"confirm"}); + + // verify + assertThat(handled).isTrue(); + verify(player).sendMessage(LkTestClickCommandExecutor.CONFIRM_MESSAGE); + } + + @Test + void onCommand_shouldReplyPlayersOnlyForNonPlayerSender() + { + // setup + final LkTestClickCommandExecutor executor = new LkTestClickCommandExecutor(); + final CommandSender sender = mock(); + final Command command = mock(); + + // execute + final boolean handled = executor.onCommand(sender, command, "lktestclick", new String[0]); + + // verify + assertThat(handled).isTrue(); + verify(sender).sendMessage(LkTestClickCommandExecutor.PLAYERS_ONLY_MESSAGE); + } + + @Test + void onCommand_shouldDispatchTellrawWithRunCommandClickEventWhenNoArgs() + { + // setup + final LkTestClickCommandExecutor executor = new LkTestClickCommandExecutor(); + final Player player = mock(); + when(player.getName()).thenReturn("lkclick01"); + final Command command = mock(); + final ConsoleCommandSender console = mock(); + final ArgumentCaptor dispatched = ArgumentCaptor.forClass(String.class); + + // execute + final boolean handled; + try (MockedStatic bukkit = mockStatic(Bukkit.class)) + { + bukkit.when(Bukkit::getConsoleSender).thenReturn(console); + handled = executor.onCommand(player, command, "lktestclick", new String[0]); + bukkit.verify(() -> Bukkit.dispatchCommand(eq(console), dispatched.capture())); + } + + // verify — a /tellraw that targets the player and carries a run_command click_event on the wire key + assertThat(handled).isTrue(); + assertThat(dispatched.getValue()) + .startsWith("minecraft:tellraw lkclick01 ") + .contains(LkTestClickCommandExecutor.CLICK_TEXT) + .contains("\"click_event\":{\"action\":\"run_command\"") + .contains("\"command\":\"/lktestclick confirm\""); + } +}