From 927b4dbc6560b886776ce1d78f8255c9fa8cd6dd Mon Sep 17 00:00:00 2001 From: milanmalhotra Date: Sat, 22 Aug 2026 12:19:50 -0400 Subject: [PATCH 1/3] test: run the end-to-end server with the real teleport and cap behavior The harness disabled the delay, cancel-on-move, safety and the max-homes cap so an assertion could never race a timer. That left all four untested. Turning them on costs nothing, because a holder of sh2.bypass-teleport-delay takes a delay of 0 and skips attempt tracking, and a holder of sh2.bypass-max-homes skips the cap. Both sit under sh2.admin. Granting sh2.player to everyone rather than to ops gives two tiers: a bot that ops keeps the instant, uncapped path, and one that does not exercises the real thing. sh2.move-home is pinned to op so a spec can prove PermissionOverrides detaches a node from the bundle it belongs to. The homes spec's permission test goes with it. Its premise was that a non-op holds nothing, which is no longer true; permissions.spec.ts covers that ground against an admin command instead. --- build.gradle.kts | 16 +++++++++++----- src/test/e2e/homes.spec.ts | 12 ------------ 2 files changed, 11 insertions(+), 17 deletions(-) diff --git a/build.gradle.kts b/build.gradle.kts index 92e3f1a..4cc6f82 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -29,19 +29,25 @@ plugwright { // CI installs Node itself; a workstation may have none. downloadNode.set(System.getenv("CI") != "true") + // sh2.player is granted to everyone and the bypass nodes stay op-only, so the + // delay, cancel-on-move and the max-homes cap bind ordinary players while an op + // keeps the instant, uncapped path. writeFiles { file( "plugins/SetHomesTwo/config.yml", """ inventoryTitle: "E2E homes" - maxHomeEnabled: false - delay: 0 - cancelOnMove: false - teleportSafety: false + maxHomeEnabled: true + maxHomesType: "singular" + maxHomes: 3 + delay: 2 + cancelOnMove: true + teleportSafety: true checkForUpdates: false debugLevel: "info" permissions: - sh2.player: op + sh2.player: true + sh2.move-home: op """.trimIndent() ) } diff --git a/src/test/e2e/homes.spec.ts b/src/test/e2e/homes.spec.ts index 847283f..cba440f 100644 --- a/src/test/e2e/homes.spec.ts +++ b/src/test/e2e/homes.spec.ts @@ -59,15 +59,3 @@ test('clicking a home in the menu teleports the player', async ({ player }) => { await expect(player).toHaveReceivedMessage('Teleported to base'); await waitUntil(() => player.bot.entity.position.distanceTo(origin) < 2); }); - -test('a player without sh2.create-home is refused and creates nothing', async ({ player }) => { - player.chat('/create-home base'); - // Paper's Brigadier tree hides a node the sender fails the permission check - // for, so a denied command reads as unknown, not as the Bukkit denial. - await expect(player).toHaveReceivedMessage('Unknown or incomplete command'); - - // list-homes is gated too, so reading the list back needs op. - await player.makeOp(); - player.chat('/list-homes'); - await expect(player).toHaveReceivedMessage('You have not created any homes yet'); -}); From b3bdf2d0b20dd6fcdf612c7366c64af55425613f Mon Sep 17 00:00:00 2001 From: milanmalhotra Date: Sat, 22 Aug 2026 12:19:50 -0400 Subject: [PATCH 2/3] test: cover permissions, teleport, the GUI, admin commands and worlds Thirty-two more end-to-end tests, taking the suite from 7 to 39 in about twenty seconds. They cover what MockBukkit cannot reach rather than restating the unit suite: real Brigadier permission gating, the teleport countdown and its cancel, inventory clicks from a real client, two players at once, and the nether and the end. Two helpers come with them. dimensions.ts clears and floors a pocket before teleporting, because the generator decides what is at any spot in the nether and teleportSafety refuses a home inside blocks. menus.ts opens a home's management menu, which needs a raw mineflayer right click since the locator API clicks with the left button only. --- src/test/e2e/admin.spec.ts | 72 ++++++++++++++++++++++ src/test/e2e/blacklist.spec.ts | 61 +++++++++++++++++++ src/test/e2e/dimensions.ts | 47 ++++++++++++++ src/test/e2e/gui.spec.ts | 67 ++++++++++++++++++++ src/test/e2e/items.spec.ts | 21 +++++++ src/test/e2e/maxhomes.spec.ts | 38 ++++++++++++ src/test/e2e/menus.ts | 19 ++++++ src/test/e2e/permissions.spec.ts | 53 ++++++++++++++++ src/test/e2e/teleport.spec.ts | 101 +++++++++++++++++++++++++++++++ src/test/e2e/worlds.spec.ts | 50 +++++++++++++++ 10 files changed, 529 insertions(+) create mode 100644 src/test/e2e/admin.spec.ts create mode 100644 src/test/e2e/blacklist.spec.ts create mode 100644 src/test/e2e/dimensions.ts create mode 100644 src/test/e2e/gui.spec.ts create mode 100644 src/test/e2e/items.spec.ts create mode 100644 src/test/e2e/maxhomes.spec.ts create mode 100644 src/test/e2e/menus.ts create mode 100644 src/test/e2e/permissions.spec.ts create mode 100644 src/test/e2e/teleport.spec.ts create mode 100644 src/test/e2e/worlds.spec.ts diff --git a/src/test/e2e/admin.spec.ts b/src/test/e2e/admin.spec.ts new file mode 100644 index 0000000..f4dcdbf --- /dev/null +++ b/src/test/e2e/admin.spec.ts @@ -0,0 +1,72 @@ +import { test, expect, waitUntil, sleep } from '@drownek/plugwright'; + +test("an admin can open another player's homes", async ({ player, createPlayer }) => { + const other = await createPlayer(); + other.chat('/create-home theirs'); + await expect(other).toHaveReceivedMessage('theirs has been created successfully'); + + await player.makeOp(); + player.chat(`/get-player-homes ${other.username}`); + + const gui = await player.gui({ title: `Homes of ${other.username}` }); + await expect.poll(() => gui.locator(i => i.getDisplayName().includes('theirs')).displayName()).toContain('theirs'); +}); + +test("an admin can teleport to another player's home", async ({ player, createPlayer, server }) => { + const other = await createPlayer(); + other.chat('/create-home theirs'); + await expect(other).toHaveReceivedMessage('theirs has been created successfully'); + + await player.makeOp(); + await player.setGameMode('spectator'); + const target = other.bot.entity.position.clone(); + await player.teleport(target.x + 80, target.y, target.z + 80); + await waitUntil(() => player.bot.entity.position.distanceTo(target) > 20); + + player.chat(`/go-player-home ${other.username} theirs`); + await waitUntil(() => player.bot.entity.position.distanceTo(target) < 4, { timeout: 15000 }); + void server; +}); + +test("an admin can move another player's home", async ({ player, createPlayer }) => { + const other = await createPlayer(); + other.chat('/create-home theirs'); + await expect(other).toHaveReceivedMessage('theirs has been created successfully'); + + await player.makeOp(); + player.chat(`/move-player-home ${other.username} theirs`); + await expect(player).toHaveReceivedMessage(`${other.username}'s home 'theirs' has been moved to your location`); +}); + +test("an admin can delete another player's home", async ({ player, createPlayer }) => { + const other = await createPlayer(); + other.chat('/create-home theirs'); + await expect(other).toHaveReceivedMessage('theirs has been created successfully'); + + await player.makeOp(); + player.chat(`/delete-player-home ${other.username} theirs`); + await expect(player).toHaveReceivedMessage(`${other.username}'s home 'theirs' has been deleted`); + + const since = other.getMessageBufferIndex(); + other.chat('/list-homes'); + await expect(other).toHaveReceivedMessage('You have not created any homes yet', { since }); +}); + +test("the admin view of another player's homes offers no management menu", async ({ player, createPlayer }) => { + const other = await createPlayer(); + other.chat('/create-home theirs'); + await expect(other).toHaveReceivedMessage('theirs has been created successfully'); + + await player.makeOp(); + player.chat(`/get-player-homes ${other.username}`); + const gui = await player.gui({ title: `Homes of ${other.username}` }); + await expect.poll(() => gui.locator(i => i.getDisplayName().includes('theirs')).displayName()).toContain('theirs'); + + const snapshot = player.getCurrentGui(); + const home = snapshot!.items.find(i => i.getDisplayName().includes('theirs')); + await player.bot.clickWindow(home!.slot, 1, 0); + + await sleep(750); + const title = player.getCurrentGui()?.title ?? ''; + expect(title.includes('Manage:')).toBe(false); +}); diff --git a/src/test/e2e/blacklist.spec.ts b/src/test/e2e/blacklist.spec.ts new file mode 100644 index 0000000..f5a3d16 --- /dev/null +++ b/src/test/e2e/blacklist.spec.ts @@ -0,0 +1,61 @@ +import { test, expect } from '@drownek/plugwright'; +import { standIn, NETHER } from './dimensions.js'; + +// The blacklist table is server-wide, so every test here removes what it adds. + +test('a dimension can be blacklisted, listed and removed', async ({ player }) => { + await player.makeOp(); + + player.chat('/blacklist add world_nether'); + await expect(player).toHaveReceivedMessage('world_nether has been added to the blacklist'); + + const listed = player.getMessageBufferIndex(); + player.chat('/blacklist list'); + await expect(player).toHaveReceivedMessage('world_nether', { since: listed }); + + player.chat('/blacklist remove world_nether'); + await expect(player).toHaveReceivedMessage('world_nether has been removed from the blacklist'); + + const emptied = player.getMessageBufferIndex(); + player.chat('/blacklist list'); + await expect(player).toHaveReceivedMessage('No dimensions are blacklisted', { since: emptied }); +}); + +test('an ordinary player cannot set a home in a blacklisted dimension', async ({ player, createPlayer, server }) => { + await player.makeOp(); + player.chat('/blacklist add world_nether'); + await expect(player).toHaveReceivedMessage('world_nether has been added to the blacklist'); + + try { + const ordinary = await createPlayer(); + await standIn(server, ordinary, NETHER); + + ordinary.chat('/create-home nether-home'); + await expect(ordinary).toHaveReceivedMessage('You cannot set a home in this dimension because it has been blacklisted'); + } finally { + player.chat('/blacklist remove world_nether'); + await expect(player).toHaveReceivedMessage('world_nether has been removed from the blacklist'); + } +}); + +test('an op holding bypass-blacklist may set a home there anyway', async ({ player, server }) => { + await player.makeOp(); + player.chat('/blacklist add world_nether'); + await expect(player).toHaveReceivedMessage('world_nether has been added to the blacklist'); + + try { + await standIn(server, player, NETHER); + player.chat('/create-home nether-home'); + await expect(player).toHaveReceivedMessage('nether-home has been created successfully'); + } finally { + player.chat('/blacklist remove world_nether'); + await expect(player).toHaveReceivedMessage('world_nether has been removed from the blacklist'); + } +}); + +test('a name that is not a world is rejected', async ({ player }) => { + await player.makeOp(); + + player.chat('/blacklist add not_a_world'); + await expect(player).toHaveReceivedMessage('is not a valid world'); +}); diff --git a/src/test/e2e/dimensions.ts b/src/test/e2e/dimensions.ts new file mode 100644 index 0000000..0f83ce0 --- /dev/null +++ b/src/test/e2e/dimensions.ts @@ -0,0 +1,47 @@ +import { waitUntil, type PlayerWrapper, type ServerWrapper } from '@drownek/plugwright'; + +export const OVERWORLD = 'world'; +export const NETHER = 'world_nether'; +export const THE_END = 'world_the_end'; + +const DIMENSION_KEY: Record = { + [OVERWORLD]: 'overworld', + [NETHER]: 'the_nether', + [THE_END]: 'the_end', +}; + +// mineflayer types bot.game.dimension as the bare key, but the server sends it +// namespaced, so accept either form. +export function isIn(player: PlayerWrapper, world: string): boolean { + const key = DIMENSION_KEY[world]; + const current = String(player.bot.game.dimension); + return current === key || current === `minecraft:${key}`; +} + +/** + * Puts the player on solid ground in the given world and waits for the client to + * report the change. + * + * The generator decides what is at any given spot in the nether and the end, and + * teleportSafety refuses a home inside blocks, so the pocket is cleared and + * floored first. + */ +export async function standIn( + server: ServerWrapper, + player: PlayerWrapper, + world: string, + x = 64, + y = 100, + z = 64, +): Promise { + const dimension = `minecraft:${DIMENSION_KEY[world]}`; + + server.execute(`minecraft:execute in ${dimension} run fill ${x - 3} ${y - 1} ${z - 3} ${x + 3} ${y + 4} ${z + 3} minecraft:air`); + server.execute(`minecraft:execute in ${dimension} run fill ${x - 3} ${y - 1} ${z - 3} ${x + 3} ${y - 1} ${z + 3} minecraft:stone`); + server.execute(`minecraft:execute in ${dimension} run tp ${player.username} ${x} ${y} ${z}`); + + await waitUntil(() => isIn(player, world), { + timeout: 20000, + message: `player never arrived in ${world}`, + }); +} diff --git a/src/test/e2e/gui.spec.ts b/src/test/e2e/gui.spec.ts new file mode 100644 index 0000000..cc3d536 --- /dev/null +++ b/src/test/e2e/gui.spec.ts @@ -0,0 +1,67 @@ +import { test, expect, sleep } from '@drownek/plugwright'; +import { openActions } from './menus.js'; + +test('right-clicking a home opens its management menu', async ({ player }) => { + player.chat('/create-home base'); + await expect(player).toHaveReceivedMessage('base has been created successfully'); + + const actions = await openActions(player, 'base'); + expect(actions.title).toContain('Manage: base'); + await expect.poll(() => actions.locator(i => i.getDisplayName().includes('Rename')).displayName()).toContain('Rename'); +}); + +test('move home here relocates the home', async ({ player }) => { + player.chat('/create-home base'); + await expect(player).toHaveReceivedMessage('base has been created successfully'); + + const actions = await openActions(player, 'base'); + await actions.locator(i => i.getDisplayName().includes('Move home here')).click(); + + await expect(player).toHaveReceivedMessage('base has been moved to your current location'); +}); + +test('set icon to held item changes the icon', async ({ player }) => { + player.chat('/create-home base'); + await expect(player).toHaveReceivedMessage('base has been created successfully'); + + await player.giveItem('diamond'); + await expect(player).toContainItem('diamond'); + const diamond = player.bot.inventory.items().find(item => item.name === 'diamond'); + await player.bot.equip(diamond!, 'hand'); + + const actions = await openActions(player, 'base'); + await actions.locator(i => i.getDisplayName().includes('Set icon to held item')).click(); + + await expect(player).toHaveReceivedMessage('The icon for base is now'); +}); + +test('delete asks for confirmation first', async ({ player }) => { + player.chat('/create-home base'); + await expect(player).toHaveReceivedMessage('base has been created successfully'); + + const actions = await openActions(player, 'base'); + await actions.locator(i => i.getDisplayName().includes('Delete')).click(); + + await expect.poll(() => actions.locator(i => i.getDisplayName().includes('Confirm delete')).displayName()).toContain('Confirm delete'); + + const since = player.getMessageBufferIndex(); + player.chat('/list-homes'); + await expect(player).toHaveReceivedMessage('base', { since }); +}); + +test('confirming the delete removes the home', async ({ player }) => { + player.chat('/create-home base'); + await expect(player).toHaveReceivedMessage('base has been created successfully'); + + const actions = await openActions(player, 'base'); + await actions.locator(i => i.getDisplayName().includes('Delete')).click(); + await expect.poll(() => actions.locator(i => i.getDisplayName().includes('Confirm delete')).displayName()).toContain('Confirm delete'); + + await actions.locator(i => i.getDisplayName().includes('Confirm delete')).click(); + await expect(player).toHaveReceivedMessage('base has been deleted successfully'); + + await sleep(250); + const since = player.getMessageBufferIndex(); + player.chat('/list-homes'); + await expect(player).toHaveReceivedMessage('You have not created any homes yet', { since }); +}); diff --git a/src/test/e2e/items.spec.ts b/src/test/e2e/items.spec.ts new file mode 100644 index 0000000..9a721e8 --- /dev/null +++ b/src/test/e2e/items.spec.ts @@ -0,0 +1,21 @@ +import { test, expect } from '@drownek/plugwright'; + +test('give-homes-item puts the homes compass in the inventory', async ({ player }) => { + player.chat('/give-homes-item'); + await expect(player).toContainItem('compass'); +}); + +test('right-clicking the compass opens the homes menu', async ({ player }) => { + player.chat('/create-home base'); + await expect(player).toHaveReceivedMessage('base has been created successfully'); + + player.chat('/give-homes-item'); + await expect(player).toContainItem('compass'); + + const compass = player.bot.inventory.items().find(item => item.name === 'compass'); + await player.bot.equip(compass!, 'hand'); + player.bot.activateItem(); + + const gui = await player.gui({ title: 'E2E homes' }); + await expect.poll(() => gui.locator(i => i.getDisplayName().includes('base')).displayName()).toContain('base'); +}); diff --git a/src/test/e2e/maxhomes.spec.ts b/src/test/e2e/maxhomes.spec.ts new file mode 100644 index 0000000..06752aa --- /dev/null +++ b/src/test/e2e/maxhomes.spec.ts @@ -0,0 +1,38 @@ +import { test, expect } from '@drownek/plugwright'; + +// The harness config sets maxHomesType singular with a cap of 3. +const CAP = 3; + +test('an ordinary player is capped at the configured maximum', async ({ player }) => { + for (let i = 0; i < CAP; i++) { + player.chat(`/create-home home${i}`); + await expect(player).toHaveReceivedMessage(`home${i} has been created successfully`); + } + + const since = player.getMessageBufferIndex(); + player.chat('/create-home one-too-many'); + await expect(player).toHaveReceivedMessage('You have reached the maximum number of homes allowed', { since }); +}); + +test('an op holding bypass-max-homes may exceed the cap', async ({ player }) => { + await player.makeOp(); + + for (let i = 0; i <= CAP; i++) { + player.chat(`/create-home home${i}`); + await expect(player).toHaveReceivedMessage(`home${i} has been created successfully`); + } +}); + +test('deleting a home frees a slot', async ({ player }) => { + for (let i = 0; i < CAP; i++) { + player.chat(`/create-home home${i}`); + await expect(player).toHaveReceivedMessage(`home${i} has been created successfully`); + } + + player.chat('/delete-home home0'); + await expect(player).toHaveReceivedMessage('home0 has been deleted successfully'); + + const since = player.getMessageBufferIndex(); + player.chat('/create-home replacement'); + await expect(player).toHaveReceivedMessage('replacement has been created successfully', { since }); +}); diff --git a/src/test/e2e/menus.ts b/src/test/e2e/menus.ts new file mode 100644 index 0000000..9c87dca --- /dev/null +++ b/src/test/e2e/menus.ts @@ -0,0 +1,19 @@ +import { expect, type PlayerWrapper } from '@drownek/plugwright'; + +/** + * Opens a home's management menu. + * + * The locator API clicks with the left button only and the menu is bound to a + * right click, so the slot goes to mineflayer directly. Button 1 is the right + * button, mode 0 an ordinary click. + */ +export async function openActions(player: PlayerWrapper, name: string) { + player.chat('/homes'); + const homes = await player.gui({ title: 'E2E homes' }); + await expect.poll(() => homes.locator(i => i.getDisplayName().includes(name)).displayName()).toContain(name); + + const home = player.getCurrentGui()!.items.find(i => i.getDisplayName().includes(name)); + await player.bot.clickWindow(home!.slot, 1, 0); + + return player.gui({ title: `Manage: ${name}` }); +} diff --git a/src/test/e2e/permissions.spec.ts b/src/test/e2e/permissions.spec.ts new file mode 100644 index 0000000..ee3b344 --- /dev/null +++ b/src/test/e2e/permissions.spec.ts @@ -0,0 +1,53 @@ +import { test, expect } from '@drownek/plugwright'; + +// Paper's Brigadier tree hides a node the sender fails the permission check +// for, so a denied command reads as unknown, not as the Bukkit denial. +const DENIED = 'Unknown or incomplete command'; + +test('an ordinary player may manage their own homes', async ({ player }) => { + player.chat('/create-home base'); + await expect(player).toHaveReceivedMessage('base has been created successfully'); + + const since = player.getMessageBufferIndex(); + player.chat('/list-homes'); + await expect(player).toHaveReceivedMessage('base', { since }); +}); + +test('an ordinary player is refused an admin command', async ({ player }) => { + player.chat('/blacklist list'); + await expect(player).toHaveReceivedMessage(DENIED); +}); + +test('an op may run the admin command', async ({ player }) => { + await player.makeOp(); + + player.chat('/blacklist list'); + await expect(player).toHaveReceivedMessage('No dimensions are blacklisted'); +}); + +test('deOp takes the admin command away again', async ({ player }) => { + await player.makeOp(); + player.chat('/blacklist list'); + await expect(player).toHaveReceivedMessage('No dimensions are blacklisted'); + + await player.deOp(); + const since = player.getMessageBufferIndex(); + player.chat('/blacklist list'); + await expect(player).toHaveReceivedMessage(DENIED, { since }); +}); + +test('a node pinned to op in the config is detached from the player bundle', async ({ player }) => { + player.chat('/create-home base'); + await expect(player).toHaveReceivedMessage('base has been created successfully'); + + // sh2.move-home is pinned to op by the harness config, which detaches it from + // the sh2.player bundle this player otherwise holds in full. + const since = player.getMessageBufferIndex(); + player.chat('/move-home base'); + await expect(player).toHaveReceivedMessage(DENIED, { since }); + + await player.makeOp(); + const afterOp = player.getMessageBufferIndex(); + player.chat('/move-home base'); + await expect(player).toHaveReceivedMessage('has been moved to your current location', { since: afterOp }); +}); diff --git a/src/test/e2e/teleport.spec.ts b/src/test/e2e/teleport.spec.ts new file mode 100644 index 0000000..507d328 --- /dev/null +++ b/src/test/e2e/teleport.spec.ts @@ -0,0 +1,101 @@ +import { test, expect, waitUntil, waitForStable, sleep } from '@drownek/plugwright'; + +// The harness config sets a two second delay with cancel-on-move and safety on. +// An op holds sh2.bypass-teleport-delay and takes neither of the first two. +// +// The move check compares the stored location to the current one with exact +// equality, so these tests stand still in spectator, where there is no physics +// to nudge the position. + +test('an ordinary player waits out the countdown before arriving', async ({ player }) => { + await player.setGameMode('spectator'); + const origin = player.bot.entity.position.clone(); + + player.chat('/create-home base'); + await expect(player).toHaveReceivedMessage('base has been created successfully'); + + await player.teleport(origin.x + 60, origin.y, origin.z + 60); + await waitUntil(() => player.bot.entity.position.distanceTo(origin) > 20); + + // The move check is exact, and an idle bot still sends position packets that + // differ in the last decimal, which reads as movement and cancels the run. + player.bot.physicsEnabled = false; + + player.chat('/home base'); + await waitForStable(() => player.bot.entity.position.distanceTo(origin) > 20, { duration: 1200 }); + await waitUntil(() => player.bot.entity.position.distanceTo(origin) < 4, { timeout: 15000 }); +}); + +test('an op holding the bypass arrives without the countdown', async ({ player }) => { + await player.makeOp(); + await player.setGameMode('spectator'); + const origin = player.bot.entity.position.clone(); + + player.chat('/create-home base'); + await expect(player).toHaveReceivedMessage('base has been created successfully'); + + await player.teleport(origin.x + 60, origin.y, origin.z + 60); + await waitUntil(() => player.bot.entity.position.distanceTo(origin) > 20); + + player.chat('/home base'); + await waitUntil(() => player.bot.entity.position.distanceTo(origin) < 4, { timeout: 1500 }); +}); + +test('moving during the countdown cancels the teleport', async ({ player }) => { + await player.setGameMode('spectator'); + const origin = player.bot.entity.position.clone(); + + player.chat('/create-home base'); + await expect(player).toHaveReceivedMessage('base has been created successfully'); + + await player.teleport(origin.x + 60, origin.y, origin.z + 60); + await waitUntil(() => player.bot.entity.position.distanceTo(origin) > 20); + + player.chat('/home base'); + await sleep(300); + await player.teleport(origin.x + 70, origin.y, origin.z + 70); + + await expect(player).toHaveReceivedMessage('Your teleport has been canceled because you have moved', { timeout: 15000 }); + await waitForStable(() => player.bot.entity.position.distanceTo(origin) > 20, { duration: 1500 }); +}); + +test('a second teleport while one is pending is refused', async ({ player }) => { + await player.setGameMode('spectator'); + const origin = player.bot.entity.position.clone(); + + player.chat('/create-home base'); + await expect(player).toHaveReceivedMessage('base has been created successfully'); + + await player.teleport(origin.x + 60, origin.y, origin.z + 60); + await waitUntil(() => player.bot.entity.position.distanceTo(origin) > 20); + + player.bot.physicsEnabled = false; + + player.chat('/home base'); + await sleep(250); + const since = player.getMessageBufferIndex(); + player.chat('/home base'); + + await expect(player).toHaveReceivedMessage('You cannot teleport while already teleporting', { since, timeout: 10000 }); +}); + +test('a home sealed in blocks is relocated or refused', async ({ player, server }) => { + await player.makeOp(); + await player.setGameMode('spectator'); + const origin = player.bot.entity.position.clone(); + + player.chat('/create-home buried'); + await expect(player).toHaveReceivedMessage('buried has been created successfully'); + + const x = Math.floor(origin.x); + const y = Math.floor(origin.y); + const z = Math.floor(origin.z); + server.execute(`minecraft:fill ${x - 1} ${y} ${z - 1} ${x + 1} ${y + 2} ${z + 1} minecraft:stone`); + await sleep(750); + + await player.teleport(origin.x + 60, origin.y, origin.z + 60); + await waitUntil(() => player.bot.entity.position.distanceTo(origin) > 20); + + player.chat('/home buried'); + await expect(player).toHaveReceivedMessage(/not safe to stand in|nearest safe spot/, { timeout: 15000 }); +}); diff --git a/src/test/e2e/worlds.spec.ts b/src/test/e2e/worlds.spec.ts new file mode 100644 index 0000000..58d7136 --- /dev/null +++ b/src/test/e2e/worlds.spec.ts @@ -0,0 +1,50 @@ +import { test, expect, waitUntil } from '@drownek/plugwright'; +import { standIn, isIn, OVERWORLD, NETHER, THE_END } from './dimensions.js'; + +test('a home can be created in the nether', async ({ player, server }) => { + await player.makeOp(); + await standIn(server, player, NETHER); + + player.chat('/create-home hell'); + await expect(player).toHaveReceivedMessage('hell has been created successfully'); +}); + +test('a home can be created in the end', async ({ player, server }) => { + await player.makeOp(); + await standIn(server, player, THE_END); + + player.chat('/create-home void'); + await expect(player).toHaveReceivedMessage('void has been created successfully'); +}); + +test('a nether home teleports the player back across dimensions', async ({ player, server }) => { + await player.makeOp(); + await standIn(server, player, NETHER); + player.chat('/create-home hell'); + await expect(player).toHaveReceivedMessage('hell has been created successfully'); + + await standIn(server, player, OVERWORLD, 0, 100, 0); + + player.chat('/home hell'); + await waitUntil(() => isIn(player, NETHER), { + timeout: 20000, + message: 'player never returned to the nether', + }); +}); + +test('list-homes shows homes from every dimension', async ({ player, server }) => { + await player.makeOp(); + + await standIn(server, player, NETHER); + player.chat('/create-home hell'); + await expect(player).toHaveReceivedMessage('hell has been created successfully'); + + await standIn(server, player, THE_END); + player.chat('/create-home void'); + await expect(player).toHaveReceivedMessage('void has been created successfully'); + + const since = player.getMessageBufferIndex(); + player.chat('/list-homes'); + await expect(player).toHaveReceivedMessage('hell', { since }); + await expect(player).toHaveReceivedMessage('void', { since }); +}); From 3e4213e50d257d8a7f0b8dccd61684b6904cb97c Mon Sep 17 00:00:00 2001 From: milanmalhotra Date: Sat, 22 Aug 2026 12:19:50 -0400 Subject: [PATCH 3/3] docs: describe the expanded end-to-end suite Records the new test count and the two permission tiers the specs test against, including why a spec that must stand still through the countdown disables the bot's physics first. --- src/README.md | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/README.md b/src/README.md index 9d84706..809f49d 100644 --- a/src/README.md +++ b/src/README.md @@ -60,7 +60,7 @@ aborts into real failures. Do not remove it. ./gradlew plugwrightTest Boots Paper 1.21.11, installs the jar you just built, and joins bot players -that run commands and click through the GUI. Seven tests, under two seconds +that run commands and click through the GUI. 39 tests, about twenty seconds once the server is up. Maven has to run first. Gradle stages the newest jar out of `target/` and never @@ -77,6 +77,13 @@ they assume is written by `build.gradle.kts`, so change a value there rather than in a spec. Logs from the last run are in `run/logs/`, and the next run wipes them. +That config grants `sh2.player` to everyone and leaves the bypass nodes on +`sh2.admin`, which gives the specs two tiers to test against. A bot that calls +`makeOp()` skips the teleport delay and the max-homes cap; one that does not +takes both. Tests that need to stand still through the countdown disable the +bot's physics first, because the move check compares locations exactly and an +idle bot still sends position packets. + CI runs the unit suite on every push and pull request, and the end-to-end suite on every pull request.