Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 11 additions & 5 deletions build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
)
}
Expand Down
9 changes: 8 additions & 1 deletion src/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.

Expand Down
72 changes: 72 additions & 0 deletions src/test/e2e/admin.spec.ts
Original file line number Diff line number Diff line change
@@ -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);
});
61 changes: 61 additions & 0 deletions src/test/e2e/blacklist.spec.ts
Original file line number Diff line number Diff line change
@@ -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');
});
47 changes: 47 additions & 0 deletions src/test/e2e/dimensions.ts
Original file line number Diff line number Diff line change
@@ -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<string, string> = {
[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<void> {
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}`,
});
}
67 changes: 67 additions & 0 deletions src/test/e2e/gui.spec.ts
Original file line number Diff line number Diff line change
@@ -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 });
});
12 changes: 0 additions & 12 deletions src/test/e2e/homes.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
});
21 changes: 21 additions & 0 deletions src/test/e2e/items.spec.ts
Original file line number Diff line number Diff line change
@@ -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');
});
38 changes: 38 additions & 0 deletions src/test/e2e/maxhomes.spec.ts
Original file line number Diff line number Diff line change
@@ -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 });
});
Loading
Loading