Skip to content

Commit 7af5e2c

Browse files
rom1504claude
andauthored
Improve test reliability: server console gamemode, retries, NaN guard (#3871)
* Use server console for gamemode changes in tests Replace the fragile triple-chat-command trick in setCreativeMode with wrap.writeServer(), which sends the gamemode command directly to the server console. Wait for the game_state_change packet to confirm. This eliminates the most common source of flaky test timeouts: "Event message did not fire within timeout of 20000ms" in setCreativeMode/becomeCreative. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * Fix game_state_change check for newer MC versions The reason field is a string ('change_game_mode') on newer versions and a number (3) on older ones. gameMode is a float, so use Math.floor. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * Use server console for clearInventory too Replace chat-based /give + /clear with wrap.writeServer('clear flatbot'). Wait for set_slot packet confirming items removed. Skip if inventory is already empty. Eliminates updateSlot timeout flakiness. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * Fix internal test timeOfDay race condition bot.time is initialized when the bot processes the login packet. Setting bot.time.timeOfDay before the login is processed causes "Cannot set properties of undefined" on fast/loaded runners. Wait for the bot's 'login' event before accessing bot.time. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * Fix clearInventory: wait for updateSlot with empty inventory check The set_slot packet format differs across MC versions (blockId vs present field). Instead of checking packet fields, wait for the higher-level updateSlot event and verify inventory is actually empty. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * Fix clearInventory: handle empty inventory + wait for sync When inventory is already empty, wait 2 ticks for any pending set_slot packets to arrive. When inventory has items, wait until all slots are cleared via updateSlot events. The server console command is reliable but we need to wait for the inventory state to sync. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * Fix: prevent sending NaN position packets after death After death, bot.entity.position can become NaN (not null). NaN serializes as null in JSON, causing "Invalid move packet received" kicks. Guard all position send functions with Number.isFinite checks. Also simplify clearInventory to not use waitForTicks (which hangs when physics tick returns early due to NaN position). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * Fix clearInventory race: don't send /clear when inventory is empty Sending /clear when inventory is already empty causes a race on 1.8.x where the server processes the clear after the next test has already set inventory slots, wiping them. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * Fix clearInventory: give stone before clear to guarantee events The /clear command produces no events when inventory is already empty (stale local state). Give a stone first via server console to ensure the server always has something to clear, guaranteeing updateSlot events. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * Fix: use bot.chat for /give, server console for /clear Server console /give produces admin chat messages that interfere with chat pattern tests. Use bot.chat for /give (as before) and server console only for /clear (which is the part that was flaky). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * Move resetState from beforeEach hook into test runner Mocha's --retries only retries tests, not hooks. When resetState was in beforeEach, any timeout there failed the test with no retry. Now resetState runs inside runTest(), so --retries 2 covers both the reset and the actual test together. One change, no test files modified. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * Fix clearInventory: use creative setInventorySlot instead of chat /give bot.chat('/give') generates chat messages that interfere with chat pattern tests (especially now that resetState runs inside the test where patterns are still registered). Use creative setInventorySlot which is silent. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * Fix clearInventory: use raw set_creative_slot instead of creative API bot.creative.setInventorySlot waits for server confirmation, which conflicts with the subsequent /clear command that removes the same slot. Use a raw set_creative_slot packet write instead — no waiting, no conflict with the clear. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * Fix clearInventory: use bot.chat for /clear, creative packet for /give Server console commands generate system_chat messages that interfere with chat pattern tests. Use: - set_creative_slot packet for giving stone (silent, no chat) - bot.chat('/clear') for clearing (the original reliable approach) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * Log retry attempts for visibility Print "[retry N]" when a test is being retried, so we can see in CI logs whether --retries 2 is actually catching transient failures. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * Increase retries to 3 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * Fix: use this.test._currentRetry for retry logging this.currentRetry doesn't exist on mocha Context — the retry count is on this.test._currentRetry. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * Disable retries after 3 distinct test failures When 3+ different tests fail, it indicates a systemic issue (not transient flakiness). Disable retries for remaining tests to avoid wasting time on cascading failures. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * Revert clearInventory to original bot.chat approach The set_creative_slot packet doesn't produce updateSlot events on 1.19.2-1.19.3, causing 5 failures per version. Go back to the original bot.chat('/give @A stone 1') + bot.chat('/clear') which works across all versions. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * Fix bail-out: count failures on first attempt, not after all retries The bail-out was counting failures only after a test exhausted all retries, so the first 3 failing tests each wasted 3 retries before triggering the bail-out. Now count on first attempt — after 3 different tests fail, remaining tests run without retries. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: rom1504 <rom1504@users.noreply.github.com> Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 85a164d commit 7af5e2c

File tree

5 files changed

+52
-39
lines changed

5 files changed

+52
-39
lines changed

.github/workflows/ci.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,7 @@ jobs:
6868
exit_code=0
6969
pids=""
7070
for v in ${{ matrix.versions }}; do
71-
npm run mocha_test -- --retries 2 -g "${v}v" > "test-${v}.log" 2>&1 &
71+
npm run mocha_test -- --retries 3 -g "${v}v" > "test-${v}.log" 2>&1 &
7272
pids="$pids $!"
7373
done
7474
for pid in $pids; do

lib/plugins/physics.js

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,7 @@ function inject (bot, { physicsEnabled, maxCatchupTicks }) {
7676
}
7777

7878
function tickPhysics (now) {
79+
if (!bot.entity?.position || !Number.isFinite(bot.entity.position.x)) return // entity not ready
7980
if (bot.blockAt(bot.entity.position) == null) return // check if chunk is unloaded
8081
if (bot.physicsEnabled && shouldUsePhysics) {
8182
physics.simulatePlayer(new PlayerState(bot, controlState), world).apply(bot)
@@ -99,6 +100,7 @@ function inject (bot, { physicsEnabled, maxCatchupTicks }) {
99100

100101
function sendPacketPosition (position, onGround) {
101102
// sends data, no logic
103+
if (!Number.isFinite(position.x) || !Number.isFinite(position.y) || !Number.isFinite(position.z)) return
102104
const oldPos = new Vec3(lastSent.x, lastSent.y, lastSent.z)
103105
lastSent.x = position.x
104106
lastSent.y = position.y
@@ -122,6 +124,7 @@ function inject (bot, { physicsEnabled, maxCatchupTicks }) {
122124

123125
function sendPacketPositionAndLook (position, yaw, pitch, onGround) {
124126
// sends data, no logic
127+
if (!Number.isFinite(position.x) || !Number.isFinite(position.y) || !Number.isFinite(position.z)) return
125128
const oldPos = new Vec3(lastSent.x, lastSent.y, lastSent.z)
126129
lastSent.x = position.x
127130
lastSent.y = position.y
@@ -153,6 +156,8 @@ function inject (bot, { physicsEnabled, maxCatchupTicks }) {
153156
function updatePosition (now) {
154157
// Only send updates for 20 ticks after death
155158
if (isEntityRemoved()) return
159+
// Don't send position with invalid coordinates (NaN after death)
160+
if (!Number.isFinite(bot.entity.position.x)) return
156161

157162
// Increment the yaw in baby steps so that notchian clients (not the server) can keep up.
158163
const dYaw = deltaYaw(bot.entity.yaw, lastSentYaw)

test/externalTest.js

Lines changed: 20 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,7 @@ for (const supportedVersion of mineflayer.testedVersions) {
6161
host: '127.0.0.1',
6262
version: supportedVersion
6363
})
64-
commonTest(bot)
64+
commonTest(bot, wrap)
6565
bot.test.port = PORT
6666

6767
console.log('starting bot')
@@ -109,12 +109,6 @@ for (const supportedVersion of mineflayer.testedVersions) {
109109
} else begin()
110110
})
111111

112-
beforeEach(async () => {
113-
console.log('Resetting state')
114-
await bot.test.resetState()
115-
console.log('State reset')
116-
})
117-
118112
after((done) => {
119113
if (bot) bot.quit()
120114
wrap.stopServer((err) => {
@@ -131,6 +125,7 @@ for (const supportedVersion of mineflayer.testedVersions) {
131125
})
132126

133127
const externalTestsFolder = path.resolve(__dirname, './externalTests')
128+
let distinctFailures = 0
134129
fs.readdirSync(externalTestsFolder)
135130
.filter(file => fs.statSync(path.join(externalTestsFolder, file)).isFile())
136131
.forEach((test) => {
@@ -139,8 +134,24 @@ for (const supportedVersion of mineflayer.testedVersions) {
139134
const runTest = (testName, testFunction) => {
140135
return function (done) {
141136
this.timeout(TEST_TIMEOUT_MS)
142-
bot.test.sayEverywhere(`### Starting ${testName}`)
143-
testFunction(bot, done).then(res => done()).catch(e => done(e))
137+
// Disable retries if too many different tests have already failed
138+
// on their first attempt (indicates a systemic issue, not flakiness)
139+
if (distinctFailures >= 3) this.retries(0)
140+
if (this.test._currentRetry > 0) {
141+
console.log(` [retry ${this.test._currentRetry}] ${testName}`)
142+
}
143+
bot.test.resetState()
144+
.then(() => {
145+
bot.test.sayEverywhere(`### Starting ${testName}`)
146+
return testFunction(bot, done)
147+
})
148+
.then(res => done())
149+
.catch(e => {
150+
if (this.test._currentRetry === 0) {
151+
distinctFailures++
152+
}
153+
done(e)
154+
})
144155
}
145156
}
146157
if (excludedTests.indexOf(test) === -1) {

test/externalTests/plugins/testCommon.js

Lines changed: 24 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ const { sleep, onceWithCleanup } = require('../../../lib/promise_utils')
99
const timeout = 20000
1010
module.exports = inject
1111

12-
function inject (bot) {
12+
function inject (bot, wrap) {
1313
console.log(bot.version)
1414

1515
bot.test = {}
@@ -103,43 +103,39 @@ function inject (bot) {
103103
return setCreativeMode(false)
104104
}
105105

106-
const gameModeChangedMessages = ['commands.gamemode.success.self', 'gameMode.changed']
107-
108106
async function setCreativeMode (value) {
109-
const getGM = val => val ? 'creative' : 'survival'
110-
// this function behaves the same whether we start in creative mode or not.
111-
// also, creative mode is always allowed for ops, even if server.properties says force-gamemode=true in survival mode.
112-
let i = 0
113-
const msgProm = onceWithCleanup(bot, 'message', {
107+
const mode = value ? 'creative' : 'survival'
108+
const modeId = value ? 1 : 0
109+
if (bot.game.gameMode === mode) return
110+
// Use server console for instant, reliable gamemode change.
111+
// The old approach (triple chat command + message parsing) was fragile
112+
// and the most common source of flaky test timeouts.
113+
const gameModePromise = onceWithCleanup(bot._client, 'game_state_change', {
114114
timeout,
115-
checkCondition: msg => gameModeChangedMessages.includes(msg.translate) && i++ > 0 && bot.game.gameMode === getGM(value)
115+
checkCondition: (packet) => {
116+
// reason is 3 (number) on old versions, 'change_game_mode' (string) on new
117+
const isGameModeChange = packet.reason === 3 || packet.reason === 'change_game_mode'
118+
return isGameModeChange && Math.floor(packet.gameMode) === modeId
119+
}
116120
})
117-
118-
// do it three times to ensure that we get feedback
119-
bot.chat(`/gamemode ${getGM(value)}`)
120-
bot.chat(`/gamemode ${getGM(!value)}`)
121-
bot.chat(`/gamemode ${getGM(value)}`)
122-
return msgProm
121+
wrap.writeServer(`gamemode ${mode} flatbot\n`)
122+
await gameModePromise
123123
}
124124

125125
async function clearInventory () {
126-
const giveStone = onceWithCleanup(bot.inventory, 'updateSlot', { timeout: 1000 * 20, checkCondition: (slot, oldItem, newItem) => newItem?.name === 'stone' })
127-
await bot.test.wait(500)
126+
// Give a stone then clear — same as the original approach.
127+
// The /give ensures the server has something to clear.
128128
bot.chat('/give @a stone 1')
129-
// Removed: was leaking a new listener every call
130-
await giveStone
131-
132-
const clearInv = onceWithCleanup(bot, 'message', {
129+
await onceWithCleanup(bot.inventory, 'updateSlot', {
130+
timeout,
131+
checkCondition: (slot, oldItem, newItem) => newItem?.name === 'stone'
132+
})
133+
const clearMsg = onceWithCleanup(bot, 'message', {
133134
timeout,
134135
checkCondition: msg => msg.translate === 'commands.clear.success.single' || msg.translate === 'commands.clear.success'
135136
})
136-
bot.chat('/clear') // don't rely on the message (as it'll come to early), wait for the result of /clear instead
137-
await clearInv
138-
139-
// Check that the inventory is clear
140-
for (const slot of bot.inventory.slots) {
141-
if (slot && slot.itemCount <= 0) throw new Error('Inventory was not cleared: ' + JSON.stringify(bot.inventory.slots))
142-
}
137+
bot.chat('/clear')
138+
await clearMsg
143139
}
144140

145141
// you need to be in creative mode for this to work

test/internalTest.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -868,9 +868,10 @@ for (const supportedVersion of mineflayer.testedVersions) {
868868
})
869869

870870
server.once('playerJoin', (client) => {
871-
bot.time.timeOfDay = 18000
872871
const loginPacket = bot.test.generateLoginPacket()
873872
client.write('login', loginPacket)
873+
// Set timeOfDay after login is processed so bot.time is initialized
874+
bot.once('login', () => { bot.time.timeOfDay = 18000 })
874875

875876
const chunk = bot.test.buildChunk()
876877

0 commit comments

Comments
 (0)