@pilaf/framework 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/StoryRunner.js +897 -0
- package/lib/helpers/events.js +54 -0
- package/lib/helpers/state.js +43 -0
- package/lib/index.js +50 -0
- package/lib/matchers/game-matchers.js +16 -0
- package/lib/reporters/pilaf-reporter.js +208 -0
- package/package.json +28 -0
|
@@ -0,0 +1,897 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* StoryRunner - Executes test stories for Pilaf
|
|
3
|
+
* Supports both YAML files and JavaScript objects
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
let yaml;
|
|
7
|
+
try {
|
|
8
|
+
yaml = require('js-yaml');
|
|
9
|
+
} catch (e) {
|
|
10
|
+
yaml = null;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const fs = require('fs');
|
|
14
|
+
const { PilafBackendFactory } = require('@pilaf/backends');
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* StoryRunner executes test stories defined in YAML format
|
|
18
|
+
*
|
|
19
|
+
* Story format:
|
|
20
|
+
* name: "Story Name"
|
|
21
|
+
* description: "Story description"
|
|
22
|
+
* setup:
|
|
23
|
+
* server:
|
|
24
|
+
* type: "paper"
|
|
25
|
+
* version: "1.21.8"
|
|
26
|
+
* players:
|
|
27
|
+
* - name: "TestPlayer"
|
|
28
|
+
* username: "testplayer"
|
|
29
|
+
* steps:
|
|
30
|
+
* - name: "Step name"
|
|
31
|
+
* action: "action_type"
|
|
32
|
+
* ...action-specific params
|
|
33
|
+
* teardown:
|
|
34
|
+
* stop_server: true
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
class StoryRunner {
|
|
38
|
+
/**
|
|
39
|
+
* Create a StoryRunner
|
|
40
|
+
* @param {Object} options - Options
|
|
41
|
+
* @param {Object} [options.logger] - Logger instance (defaults to console)
|
|
42
|
+
* @param {Object} [options.reporter] - Reporter instance for collecting results
|
|
43
|
+
*/
|
|
44
|
+
constructor(options = {}) {
|
|
45
|
+
this.logger = options?.logger || console;
|
|
46
|
+
this.reporter = options?.reporter;
|
|
47
|
+
this.backends = {
|
|
48
|
+
rcon: null,
|
|
49
|
+
players: new Map() // username -> backend
|
|
50
|
+
};
|
|
51
|
+
this.bots = new Map(); // username -> bot
|
|
52
|
+
this.currentStory = null;
|
|
53
|
+
this.results = [];
|
|
54
|
+
this.variables = new Map(); // Variable storage for store_as mechanism
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Load a story from a YAML file
|
|
59
|
+
* @param {string} filePath - Path to YAML story file
|
|
60
|
+
* @returns {Object} Parsed story object
|
|
61
|
+
*/
|
|
62
|
+
loadStory(filePath) {
|
|
63
|
+
if (!yaml) {
|
|
64
|
+
throw new Error('js-yaml module is not installed. Please install it with: npm install js-yaml');
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
try {
|
|
68
|
+
const fileContents = fs.readFileSync(filePath, 'utf8');
|
|
69
|
+
const story = yaml.load(fileContents);
|
|
70
|
+
|
|
71
|
+
// Validate story structure
|
|
72
|
+
if (!story.name) {
|
|
73
|
+
throw new Error('Story must have a "name" field');
|
|
74
|
+
}
|
|
75
|
+
if (!story.steps || !Array.isArray(story.steps)) {
|
|
76
|
+
throw new Error('Story must have a "steps" array');
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
this.currentStory = story;
|
|
80
|
+
this.logger.log(`[StoryRunner] Loaded story: ${story.name}`);
|
|
81
|
+
return story;
|
|
82
|
+
} catch (error) {
|
|
83
|
+
throw new Error(`Failed to load story from ${filePath}: ${error.message}`);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Execute a story
|
|
89
|
+
* @param {Object|string} story - Story object or path to YAML file
|
|
90
|
+
* @returns {Promise<{success: boolean, results: Array}>}
|
|
91
|
+
*/
|
|
92
|
+
async execute(story) {
|
|
93
|
+
// Load story if path is provided
|
|
94
|
+
if (typeof story === 'string') {
|
|
95
|
+
this.loadStory(story);
|
|
96
|
+
} else {
|
|
97
|
+
this.currentStory = story;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const storyName = this.currentStory.name;
|
|
101
|
+
this.logger.log(`[StoryRunner] Starting story: ${storyName}`);
|
|
102
|
+
|
|
103
|
+
// Clear variables from previous story
|
|
104
|
+
this.variables.clear();
|
|
105
|
+
|
|
106
|
+
const startTime = Date.now();
|
|
107
|
+
const storyResults = {
|
|
108
|
+
story: storyName,
|
|
109
|
+
steps: [],
|
|
110
|
+
success: true,
|
|
111
|
+
error: null,
|
|
112
|
+
duration: 0
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
try {
|
|
116
|
+
// Execute setup
|
|
117
|
+
if (this.currentStory.setup) {
|
|
118
|
+
await this.executeSetup(this.currentStory.setup);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Execute steps
|
|
122
|
+
for (let i = 0; i < this.currentStory.steps.length; i++) {
|
|
123
|
+
const step = this.currentStory.steps[i];
|
|
124
|
+
this.logger.log(`[StoryRunner] Step ${i + 1}/${this.currentStory.steps.length}: ${step.name}`);
|
|
125
|
+
|
|
126
|
+
const stepResult = await this.executeStep(step);
|
|
127
|
+
storyResults.steps.push(stepResult);
|
|
128
|
+
|
|
129
|
+
if (!stepResult.success) {
|
|
130
|
+
storyResults.success = false;
|
|
131
|
+
storyResults.error = `Step "${step.name}" failed: ${stepResult.error}`;
|
|
132
|
+
break;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// Execute teardown
|
|
137
|
+
if (this.currentStory.teardown) {
|
|
138
|
+
await this.executeTeardown(this.currentStory.teardown);
|
|
139
|
+
}
|
|
140
|
+
} catch (error) {
|
|
141
|
+
storyResults.success = false;
|
|
142
|
+
storyResults.error = error.message;
|
|
143
|
+
this.logger.log(`[StoryRunner] Error: ${error.message}`);
|
|
144
|
+
if (error.stack) {
|
|
145
|
+
this.logger.log(`[StoryRunner] Stack: ${error.stack.split('\n').slice(0, 3).join('\n')}`);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
storyResults.duration = Date.now() - startTime;
|
|
150
|
+
this.logger.log(`[StoryRunner] Story ${storyName} ${storyResults.success ? 'PASSED' : 'FAILED'} (${storyResults.duration}ms)`);
|
|
151
|
+
|
|
152
|
+
return storyResults;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Execute story setup
|
|
157
|
+
* @param {Object} setup - Setup configuration
|
|
158
|
+
* @returns {Promise<void>}
|
|
159
|
+
*/
|
|
160
|
+
async executeSetup(setup) {
|
|
161
|
+
// Connect RCON backend
|
|
162
|
+
if (setup.server) {
|
|
163
|
+
const rconConfig = {
|
|
164
|
+
host: process.env.RCON_HOST || 'localhost',
|
|
165
|
+
port: parseInt(process.env.RCON_PORT) || 25575,
|
|
166
|
+
password: process.env.RCON_PASSWORD || 'cavarest'
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
this.backends.rcon = await PilafBackendFactory.create('rcon', rconConfig);
|
|
170
|
+
this.logger.log('[StoryRunner] RCON backend connected');
|
|
171
|
+
|
|
172
|
+
// Wait for server to be ready using RCON list command
|
|
173
|
+
await this.waitForServerReady();
|
|
174
|
+
this.logger.log('[StoryRunner] Server is ready');
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// Create player backends
|
|
178
|
+
if (setup.players && Array.isArray(setup.players)) {
|
|
179
|
+
for (const playerConfig of setup.players) {
|
|
180
|
+
await this.createPlayer(playerConfig);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Wait for server to be ready using RCON
|
|
187
|
+
* @returns {Promise<void>}
|
|
188
|
+
*/
|
|
189
|
+
async waitForServerReady() {
|
|
190
|
+
const startTime = Date.now();
|
|
191
|
+
const timeout = 120000;
|
|
192
|
+
const interval = 3000;
|
|
193
|
+
|
|
194
|
+
while (Date.now() - startTime < timeout) {
|
|
195
|
+
try {
|
|
196
|
+
await this.backends.rcon.send('list');
|
|
197
|
+
return; // Server is ready
|
|
198
|
+
} catch (error) {
|
|
199
|
+
this.logger.log(`[StoryRunner] Server not ready yet, waiting... (${error.message})`);
|
|
200
|
+
await new Promise(resolve => setTimeout(resolve, interval));
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
throw new Error('Server did not become ready within timeout period');
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Execute story teardown
|
|
209
|
+
* @param {Object} teardown - Teardown configuration
|
|
210
|
+
* @returns {Promise<void>}
|
|
211
|
+
*/
|
|
212
|
+
async executeTeardown(teardown) {
|
|
213
|
+
// Disconnect all bots
|
|
214
|
+
for (const [username, bot] of this.bots) {
|
|
215
|
+
const backend = this.backends.players.get(username);
|
|
216
|
+
if (backend) {
|
|
217
|
+
await backend.quitBot(bot);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
this.bots.clear();
|
|
221
|
+
|
|
222
|
+
// Disconnect all player backends
|
|
223
|
+
for (const [username, backend] of this.backends.players) {
|
|
224
|
+
await backend.disconnect();
|
|
225
|
+
}
|
|
226
|
+
this.backends.players.clear();
|
|
227
|
+
|
|
228
|
+
// Always disconnect RCON to prevent open handles
|
|
229
|
+
if (this.backends.rcon) {
|
|
230
|
+
// Stop server if requested (send message before disconnect)
|
|
231
|
+
if (teardown.stop_server) {
|
|
232
|
+
await this.backends.rcon.send('say [Pilaf] Server stopping...');
|
|
233
|
+
}
|
|
234
|
+
// Always disconnect to prevent Jest hanging on open handles
|
|
235
|
+
await this.backends.rcon.disconnect();
|
|
236
|
+
this.backends.rcon = null;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// Wait for cleanup to complete
|
|
240
|
+
await new Promise(resolve => setTimeout(resolve, 500));
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Create a player bot
|
|
245
|
+
* @param {Object} playerConfig - Player configuration
|
|
246
|
+
* @param {string} playerConfig.name - Player name
|
|
247
|
+
* @param {string} playerConfig.username - Bot username
|
|
248
|
+
* @returns {Promise<void>}
|
|
249
|
+
*/
|
|
250
|
+
async createPlayer(playerConfig) {
|
|
251
|
+
const { name, username } = playerConfig;
|
|
252
|
+
|
|
253
|
+
if (!username) {
|
|
254
|
+
throw new Error(`Player "${name}" must have a username`);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// Wait a bit before creating new bot to avoid connection conflicts
|
|
258
|
+
await new Promise(resolve => setTimeout(resolve, 200));
|
|
259
|
+
|
|
260
|
+
// Create player backend
|
|
261
|
+
const backend = await PilafBackendFactory.create('mineflayer', {
|
|
262
|
+
host: process.env.MC_HOST || 'localhost',
|
|
263
|
+
port: parseInt(process.env.MC_PORT) || 25565,
|
|
264
|
+
auth: 'offline',
|
|
265
|
+
rconHost: process.env.RCON_HOST || 'localhost',
|
|
266
|
+
rconPort: parseInt(process.env.RCON_PORT) || 25575,
|
|
267
|
+
rconPassword: process.env.RCON_PASSWORD || 'cavarest'
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
// Wait for server to be ready
|
|
271
|
+
await backend.waitForServerReady({ timeout: 60000, interval: 3000 });
|
|
272
|
+
|
|
273
|
+
// Create bot with retry logic
|
|
274
|
+
let bot;
|
|
275
|
+
let lastError;
|
|
276
|
+
for (let attempt = 1; attempt <= 3; attempt++) {
|
|
277
|
+
try {
|
|
278
|
+
bot = await backend.createBot({
|
|
279
|
+
username,
|
|
280
|
+
spawnTimeout: 60000
|
|
281
|
+
});
|
|
282
|
+
break; // Success, exit retry loop
|
|
283
|
+
} catch (error) {
|
|
284
|
+
lastError = error;
|
|
285
|
+
if (attempt < 3) {
|
|
286
|
+
this.logger.log(`[StoryRunner] Bot creation attempt ${attempt} failed: ${error.message}, retrying...`);
|
|
287
|
+
await new Promise(resolve => setTimeout(resolve, 1000 * attempt));
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
if (!bot) {
|
|
293
|
+
throw new Error(`Failed to create bot after 3 attempts: ${lastError.message}`);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
this.backends.players.set(username, backend);
|
|
297
|
+
this.bots.set(username, bot);
|
|
298
|
+
|
|
299
|
+
this.logger.log(`[StoryRunner] Created player: ${username}`);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* Resolve variable references in step parameters
|
|
304
|
+
* @param {Object} step - Step configuration
|
|
305
|
+
* @returns {Object} Step with resolved variables
|
|
306
|
+
*/
|
|
307
|
+
resolveVariables(step) {
|
|
308
|
+
const resolved = { ...step };
|
|
309
|
+
|
|
310
|
+
const resolveValue = (value) => {
|
|
311
|
+
if (typeof value === 'string') {
|
|
312
|
+
// Check for {variableName} pattern
|
|
313
|
+
const match = value.match(/^\{(.+)\}$/);
|
|
314
|
+
if (match) {
|
|
315
|
+
const varName = match[1];
|
|
316
|
+
if (!this.variables.has(varName)) {
|
|
317
|
+
throw new Error(`Variable "${varName}" not found. Available variables: ${[...this.variables.keys()].join(', ')}`);
|
|
318
|
+
}
|
|
319
|
+
return this.variables.get(varName);
|
|
320
|
+
}
|
|
321
|
+
return value;
|
|
322
|
+
} else if (Array.isArray(value)) {
|
|
323
|
+
return value.map(resolveValue);
|
|
324
|
+
} else if (value && typeof value === 'object') {
|
|
325
|
+
const resolvedObj = {};
|
|
326
|
+
for (const [key, val] of Object.entries(value)) {
|
|
327
|
+
resolvedObj[key] = resolveValue(val);
|
|
328
|
+
}
|
|
329
|
+
return resolvedObj;
|
|
330
|
+
}
|
|
331
|
+
return value;
|
|
332
|
+
};
|
|
333
|
+
|
|
334
|
+
// Resolve all values in the step (except action and store_as which should remain as-is)
|
|
335
|
+
for (const [key, value] of Object.entries(resolved)) {
|
|
336
|
+
if (key !== 'action' && key !== 'store_as') {
|
|
337
|
+
resolved[key] = resolveValue(value);
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
return resolved;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/**
|
|
345
|
+
* Execute a single step
|
|
346
|
+
* @param {Object} step - Step configuration
|
|
347
|
+
* @returns {Promise<{success: boolean, error: string|null}>}
|
|
348
|
+
*/
|
|
349
|
+
async executeStep(step) {
|
|
350
|
+
const { action, store_as } = step;
|
|
351
|
+
|
|
352
|
+
if (!action) {
|
|
353
|
+
return {
|
|
354
|
+
success: false,
|
|
355
|
+
error: 'Step must have an "action" field',
|
|
356
|
+
step: step.name
|
|
357
|
+
};
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
try {
|
|
361
|
+
// Resolve any variable references in step parameters
|
|
362
|
+
const resolvedParams = this.resolveVariables(step);
|
|
363
|
+
|
|
364
|
+
// Execute action and capture result
|
|
365
|
+
const result = await this.executeAction(action, resolvedParams);
|
|
366
|
+
|
|
367
|
+
// Store result if store_as is specified
|
|
368
|
+
if (store_as && result !== undefined) {
|
|
369
|
+
this.variables.set(store_as, result);
|
|
370
|
+
this.logger.log(`[StoryRunner] Stored result as "${store_as}"`);
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
return {
|
|
374
|
+
success: true,
|
|
375
|
+
error: null,
|
|
376
|
+
step: step.name
|
|
377
|
+
};
|
|
378
|
+
} catch (error) {
|
|
379
|
+
return {
|
|
380
|
+
success: false,
|
|
381
|
+
error: error.message,
|
|
382
|
+
step: step.name
|
|
383
|
+
};
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
/**
|
|
388
|
+
* Execute an action
|
|
389
|
+
* @param {string} action - Action type
|
|
390
|
+
* @param {Object} params - Action parameters
|
|
391
|
+
* @returns {Promise<any>} Action result (if any)
|
|
392
|
+
*/
|
|
393
|
+
async executeAction(action, params) {
|
|
394
|
+
const handler = this.actionHandlers[action];
|
|
395
|
+
|
|
396
|
+
if (!handler) {
|
|
397
|
+
throw new Error(`Unknown action: ${action}`);
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
return await handler.call(this, params);
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
/**
|
|
404
|
+
* Action handlers
|
|
405
|
+
*/
|
|
406
|
+
actionHandlers = {
|
|
407
|
+
/**
|
|
408
|
+
* Execute a command via RCON
|
|
409
|
+
*/
|
|
410
|
+
async execute_command(params) {
|
|
411
|
+
const { command } = params;
|
|
412
|
+
if (!command) {
|
|
413
|
+
throw new Error('execute_command requires "command" parameter');
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
this.logger.log(`[StoryRunner] ACTION: RCON ${command}`);
|
|
417
|
+
|
|
418
|
+
const result = await this.backends.rcon.send(command);
|
|
419
|
+
this.logger.log(`[StoryRunner] RESPONSE: ${result.raw}`);
|
|
420
|
+
},
|
|
421
|
+
|
|
422
|
+
/**
|
|
423
|
+
* Send a chat message from a player
|
|
424
|
+
*/
|
|
425
|
+
async chat(params) {
|
|
426
|
+
const { player, message } = params;
|
|
427
|
+
if (!player) {
|
|
428
|
+
throw new Error('chat requires "player" parameter');
|
|
429
|
+
}
|
|
430
|
+
if (!message) {
|
|
431
|
+
throw new Error('chat requires "message" parameter');
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
const bot = this.bots.get(player);
|
|
435
|
+
if (!bot) {
|
|
436
|
+
throw new Error(`Player "${player}" not found`);
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
this.logger.log(`[StoryRunner] ACTION: ${player} chat: ${message}`);
|
|
440
|
+
bot.chat(message);
|
|
441
|
+
this.logger.log(`[StoryRunner] RESPONSE: Message sent`);
|
|
442
|
+
},
|
|
443
|
+
|
|
444
|
+
/**
|
|
445
|
+
* Wait for a specified duration
|
|
446
|
+
*/
|
|
447
|
+
async wait(params) {
|
|
448
|
+
const { duration = 1 } = params;
|
|
449
|
+
await new Promise(resolve => setTimeout(resolve, duration * 1000));
|
|
450
|
+
this.logger.log(`[StoryRunner] Waited ${duration}s`);
|
|
451
|
+
},
|
|
452
|
+
|
|
453
|
+
/**
|
|
454
|
+
* Assert a condition
|
|
455
|
+
*/
|
|
456
|
+
async assert(params) {
|
|
457
|
+
const { condition, expected, actual, contains, not_empty } = params;
|
|
458
|
+
|
|
459
|
+
if (condition === 'equals') {
|
|
460
|
+
if (actual !== expected) {
|
|
461
|
+
throw new Error(`Assertion failed: expected "${expected}" but got "${actual}"`);
|
|
462
|
+
}
|
|
463
|
+
this.logger.log(`[StoryRunner] Assertion passed: ${actual} equals ${expected}`);
|
|
464
|
+
} else if (condition === 'contains') {
|
|
465
|
+
if (!actual || !actual.includes(expected)) {
|
|
466
|
+
throw new Error(`Assertion failed: "${actual}" does not contain "${expected}"`);
|
|
467
|
+
}
|
|
468
|
+
this.logger.log(`[StoryRunner] Assertion passed: "${actual}" contains "${expected}"`);
|
|
469
|
+
} else if (condition === 'not_empty') {
|
|
470
|
+
const value = not_empty || actual;
|
|
471
|
+
const isEmpty = Array.isArray(value) ? value.length === 0 : !value;
|
|
472
|
+
if (isEmpty) {
|
|
473
|
+
throw new Error(`Assertion failed: value is empty`);
|
|
474
|
+
}
|
|
475
|
+
this.logger.log(`[StoryRunner] Assertion passed: value is not empty`);
|
|
476
|
+
} else if (condition === 'entity_exists') {
|
|
477
|
+
// expected: entity name to check for
|
|
478
|
+
// actual: array of entities from get_entities
|
|
479
|
+
const entity = actual.find(e =>
|
|
480
|
+
e.name === expected ||
|
|
481
|
+
e.customName === expected ||
|
|
482
|
+
e.displayName === expected ||
|
|
483
|
+
e.customName?.text === expected
|
|
484
|
+
);
|
|
485
|
+
if (!entity) {
|
|
486
|
+
// Debug: log available entity names
|
|
487
|
+
const availableNames = actual.map(e => e.name || e.displayName || e.customName).slice(0, 10).join(', ');
|
|
488
|
+
this.logger.log(`[StoryRunner] Available entities (first 10): ${availableNames}`);
|
|
489
|
+
throw new Error(`Assertion failed: entity "${expected}" not found`);
|
|
490
|
+
}
|
|
491
|
+
this.logger.log(`[StoryRunner] Assertion passed: entity "${expected}" exists`);
|
|
492
|
+
} else if (condition === 'entity_not_exists') {
|
|
493
|
+
// expected: entity name to check for
|
|
494
|
+
// actual: array of entities from get_entities
|
|
495
|
+
const entity = actual.find(e =>
|
|
496
|
+
e.name === expected ||
|
|
497
|
+
e.customName === expected ||
|
|
498
|
+
e.displayName === expected ||
|
|
499
|
+
e.customName?.text === expected
|
|
500
|
+
);
|
|
501
|
+
if (entity) {
|
|
502
|
+
throw new Error(`Assertion failed: entity "${expected}" still exists`);
|
|
503
|
+
}
|
|
504
|
+
this.logger.log(`[StoryRunner] Assertion passed: entity "${expected}" does not exist`);
|
|
505
|
+
} else if (condition === 'has_item') {
|
|
506
|
+
// expected: item name
|
|
507
|
+
// actual: inventory from get_player_inventory
|
|
508
|
+
const hasItem = actual.items.some(item => item && item.name === expected);
|
|
509
|
+
if (!hasItem) {
|
|
510
|
+
throw new Error(`Assertion failed: player does not have item "${expected}"`);
|
|
511
|
+
}
|
|
512
|
+
this.logger.log(`[StoryRunner] Assertion passed: player has item "${expected}"`);
|
|
513
|
+
} else if (condition === 'does_not_have_item') {
|
|
514
|
+
// expected: item name
|
|
515
|
+
// actual: inventory from get_player_inventory
|
|
516
|
+
const hasItem = actual.items.some(item => item && item.name === expected);
|
|
517
|
+
if (hasItem) {
|
|
518
|
+
throw new Error(`Assertion failed: player still has item "${expected}"`);
|
|
519
|
+
}
|
|
520
|
+
this.logger.log(`[StoryRunner] Assertion passed: player does not have item "${expected}"`);
|
|
521
|
+
} else if (condition === 'greater_than') {
|
|
522
|
+
const actualNum = parseFloat(actual);
|
|
523
|
+
const expectedNum = parseFloat(expected);
|
|
524
|
+
if (isNaN(actualNum) || isNaN(expectedNum)) {
|
|
525
|
+
throw new Error(`Assertion failed: cannot compare non-numeric values "${actual}" and "${expected}"`);
|
|
526
|
+
}
|
|
527
|
+
if (actualNum <= expectedNum) {
|
|
528
|
+
throw new Error(`Assertion failed: ${actualNum} is not greater than ${expectedNum}`);
|
|
529
|
+
}
|
|
530
|
+
this.logger.log(`[StoryRunner] Assertion passed: ${actualNum} > ${expectedNum}`);
|
|
531
|
+
} else if (condition === 'less_than') {
|
|
532
|
+
const actualNum = parseFloat(actual);
|
|
533
|
+
const expectedNum = parseFloat(expected);
|
|
534
|
+
if (isNaN(actualNum) || isNaN(expectedNum)) {
|
|
535
|
+
throw new Error(`Assertion failed: cannot compare non-numeric values "${actual}" and "${expected}"`);
|
|
536
|
+
}
|
|
537
|
+
if (actualNum >= expectedNum) {
|
|
538
|
+
throw new Error(`Assertion failed: ${actualNum} is not less than ${expectedNum}`);
|
|
539
|
+
}
|
|
540
|
+
this.logger.log(`[StoryRunner] Assertion passed: ${actualNum} < ${expectedNum}`);
|
|
541
|
+
} else if (condition === 'greater_than_or_equals') {
|
|
542
|
+
const actualNum = parseFloat(actual);
|
|
543
|
+
const expectedNum = parseFloat(expected);
|
|
544
|
+
if (isNaN(actualNum) || isNaN(expectedNum)) {
|
|
545
|
+
throw new Error(`Assertion failed: cannot compare non-numeric values "${actual}" and "${expected}"`);
|
|
546
|
+
}
|
|
547
|
+
if (actualNum < expectedNum) {
|
|
548
|
+
throw new Error(`Assertion failed: ${actualNum} is not greater than or equal to ${expectedNum}`);
|
|
549
|
+
}
|
|
550
|
+
this.logger.log(`[StoryRunner] Assertion passed: ${actualNum} >= ${expectedNum}`);
|
|
551
|
+
} else if (condition === 'less_than_or_equals') {
|
|
552
|
+
const actualNum = parseFloat(actual);
|
|
553
|
+
const expectedNum = parseFloat(expected);
|
|
554
|
+
if (isNaN(actualNum) || isNaN(expectedNum)) {
|
|
555
|
+
throw new Error(`Assertion failed: cannot compare non-numeric values "${actual}" and "${expected}"`);
|
|
556
|
+
}
|
|
557
|
+
if (actualNum > expectedNum) {
|
|
558
|
+
throw new Error(`Assertion failed: ${actualNum} is not less than or equal to ${expectedNum}`);
|
|
559
|
+
}
|
|
560
|
+
this.logger.log(`[StoryRunner] Assertion passed: ${actualNum} <= ${expectedNum}`);
|
|
561
|
+
} else {
|
|
562
|
+
throw new Error(`Unknown assertion condition: ${condition}`);
|
|
563
|
+
}
|
|
564
|
+
},
|
|
565
|
+
|
|
566
|
+
/**
|
|
567
|
+
* REAL logout - disconnect the TCP connection
|
|
568
|
+
* This tests actual server-side session persistence
|
|
569
|
+
*/
|
|
570
|
+
async logout(params) {
|
|
571
|
+
const { player } = params;
|
|
572
|
+
if (!player) {
|
|
573
|
+
throw new Error('logout requires "player" parameter');
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
const bot = this.bots.get(player);
|
|
577
|
+
const backend = this.backends.players.get(player);
|
|
578
|
+
|
|
579
|
+
if (!bot || !backend) {
|
|
580
|
+
throw new Error(`Player "${player}" not found`);
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
// REAL TCP disconnect - quit the bot
|
|
584
|
+
const result = await backend.quitBot(bot);
|
|
585
|
+
this.bots.delete(player);
|
|
586
|
+
|
|
587
|
+
if (!result.success) {
|
|
588
|
+
throw new Error(`Logout failed: ${result.reason}`);
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
this.logger.log(`[StoryRunner] ${player} logged out (REAL TCP disconnect)`);
|
|
592
|
+
},
|
|
593
|
+
|
|
594
|
+
/**
|
|
595
|
+
* REAL login - create new TCP connection
|
|
596
|
+
* This tests actual server-side session persistence
|
|
597
|
+
*/
|
|
598
|
+
async login(params) {
|
|
599
|
+
const { player } = params;
|
|
600
|
+
if (!player) {
|
|
601
|
+
throw new Error('login requires "player" parameter');
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
// Check if player was previously logged in (has backend config)
|
|
605
|
+
const oldBackend = this.backends.players.get(player);
|
|
606
|
+
if (!oldBackend) {
|
|
607
|
+
throw new Error(`Player "${player}" was never logged in`);
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
// Check if bot already exists
|
|
611
|
+
if (this.bots.get(player)) {
|
|
612
|
+
throw new Error(`Player "${player}" is already logged in`);
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
// Get the connection config from the existing backend
|
|
616
|
+
const host = oldBackend.host;
|
|
617
|
+
const port = oldBackend.port;
|
|
618
|
+
const auth = oldBackend.auth;
|
|
619
|
+
|
|
620
|
+
// CRITICAL: Disconnect the OLD backend to prevent resource leak
|
|
621
|
+
// The bot was already quit during logout, so we just need to clear the backend
|
|
622
|
+
// We don't need to call disconnect() since the bot pool is already empty
|
|
623
|
+
try {
|
|
624
|
+
// Just clear any references - bot was already quit during logout
|
|
625
|
+
oldBackend._botPool?.clear();
|
|
626
|
+
} catch (error) {
|
|
627
|
+
// Ignore cleanup errors
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
// CRITICAL: Create a FRESH backend instance for reconnection
|
|
631
|
+
// This avoids any residual state issues (like GitHub issue #865)
|
|
632
|
+
const freshBackend = await PilafBackendFactory.create('mineflayer', {
|
|
633
|
+
host,
|
|
634
|
+
port,
|
|
635
|
+
auth,
|
|
636
|
+
rconHost: process.env.RCON_HOST || 'localhost',
|
|
637
|
+
rconPort: parseInt(process.env.RCON_PORT) || 25575,
|
|
638
|
+
rconPassword: process.env.RCON_PASSWORD || 'cavarest'
|
|
639
|
+
});
|
|
640
|
+
|
|
641
|
+
// Wait for server to be ready (avoid connection throttling)
|
|
642
|
+
await freshBackend.waitForServerReady({ timeout: 60000, interval: 2000 });
|
|
643
|
+
|
|
644
|
+
// Create new bot (REAL TCP connection)
|
|
645
|
+
const bot = await freshBackend.createBot({
|
|
646
|
+
username: player,
|
|
647
|
+
spawnTimeout: 60000
|
|
648
|
+
});
|
|
649
|
+
|
|
650
|
+
// Update backend reference - old backend reference will be garbage collected
|
|
651
|
+
this.backends.players.set(player, freshBackend);
|
|
652
|
+
this.bots.set(player, bot);
|
|
653
|
+
|
|
654
|
+
this.logger.log(`[StoryRunner] ${player} logged in (REAL TCP connection)`);
|
|
655
|
+
},
|
|
656
|
+
|
|
657
|
+
/**
|
|
658
|
+
* Kill a player (real game event)
|
|
659
|
+
*/
|
|
660
|
+
async kill(params) {
|
|
661
|
+
const { player } = params;
|
|
662
|
+
if (!player) {
|
|
663
|
+
throw new Error('kill requires "player" parameter');
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
// Use /kill command via RCON
|
|
667
|
+
await this.backends.rcon.send(`kill ${player}`);
|
|
668
|
+
this.logger.log(`[StoryRunner] ${player} killed`);
|
|
669
|
+
},
|
|
670
|
+
|
|
671
|
+
/**
|
|
672
|
+
* Wait for player respawn (real game event)
|
|
673
|
+
*/
|
|
674
|
+
async respawn(params) {
|
|
675
|
+
const { player } = params;
|
|
676
|
+
if (!player) {
|
|
677
|
+
throw new Error('respawn requires "player" parameter');
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
const bot = this.bots.get(player);
|
|
681
|
+
if (!bot) {
|
|
682
|
+
throw new Error(`Player "${player}" not found`);
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
// Wait for respawn event
|
|
686
|
+
await new Promise((resolve, reject) => {
|
|
687
|
+
const timeout = setTimeout(() => {
|
|
688
|
+
bot.removeListener('respawn', respawnHandler);
|
|
689
|
+
reject(new Error('Respawn timeout'));
|
|
690
|
+
}, 10000);
|
|
691
|
+
|
|
692
|
+
const respawnHandler = () => {
|
|
693
|
+
clearTimeout(timeout);
|
|
694
|
+
resolve();
|
|
695
|
+
};
|
|
696
|
+
|
|
697
|
+
bot.once('respawn', respawnHandler);
|
|
698
|
+
});
|
|
699
|
+
|
|
700
|
+
this.logger.log(`[StoryRunner] ${player} respawned`);
|
|
701
|
+
},
|
|
702
|
+
|
|
703
|
+
/**
|
|
704
|
+
* Move player forward
|
|
705
|
+
*/
|
|
706
|
+
async move_forward(params) {
|
|
707
|
+
const { player, duration = 1 } = params;
|
|
708
|
+
if (!player) {
|
|
709
|
+
throw new Error('move_forward requires "player" parameter');
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
const bot = this.bots.get(player);
|
|
713
|
+
if (!bot) {
|
|
714
|
+
throw new Error(`Player "${player}" not found`);
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
bot.setControlState('forward', true);
|
|
718
|
+
await new Promise(resolve => setTimeout(resolve, duration * 1000));
|
|
719
|
+
bot.setControlState('forward', false);
|
|
720
|
+
|
|
721
|
+
this.logger.log(`[StoryRunner] ${player} moved forward for ${duration}s`);
|
|
722
|
+
},
|
|
723
|
+
|
|
724
|
+
/**
|
|
725
|
+
* Get entities from player's perspective
|
|
726
|
+
* Returns array of entities visible to the player
|
|
727
|
+
*/
|
|
728
|
+
async get_entities(params) {
|
|
729
|
+
const { player } = params;
|
|
730
|
+
if (!player) {
|
|
731
|
+
throw new Error('get_entities requires "player" parameter');
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
const backend = this.backends.players.get(player);
|
|
735
|
+
if (!backend) {
|
|
736
|
+
throw new Error(`Player "${player}" backend not found`);
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
this.logger.log(`[StoryRunner] ACTION: getEntities() for ${player}`);
|
|
740
|
+
|
|
741
|
+
const entities = await backend.getEntities();
|
|
742
|
+
this.logger.log(`[StoryRunner] RESPONSE: Found ${entities.length} entities: ${entities.slice(0, 5).map(e => e.name || e.customName || e.id).join(', ')}${entities.length > 5 ? '...' : ''}`);
|
|
743
|
+
|
|
744
|
+
// Return entities for use in assertions/steps
|
|
745
|
+
return entities;
|
|
746
|
+
},
|
|
747
|
+
|
|
748
|
+
/**
|
|
749
|
+
* Get player inventory
|
|
750
|
+
* Returns player's inventory contents
|
|
751
|
+
*/
|
|
752
|
+
async get_player_inventory(params) {
|
|
753
|
+
const { player } = params;
|
|
754
|
+
if (!player) {
|
|
755
|
+
throw new Error('get_player_inventory requires "player" parameter');
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
const backend = this.backends.players.get(player);
|
|
759
|
+
if (!backend) {
|
|
760
|
+
throw new Error(`Player "${player}" backend not found`);
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
this.logger.log(`[StoryRunner] ACTION: getPlayerInventory() for ${player}`);
|
|
764
|
+
|
|
765
|
+
const inventory = await backend.getPlayerInventory(player);
|
|
766
|
+
const itemCount = inventory.items?.length || 0;
|
|
767
|
+
const itemSummary = itemCount > 0
|
|
768
|
+
? inventory.items.slice(0, 5).map(i => i.type || i.name).join(', ') + (itemCount > 5 ? '...' : '')
|
|
769
|
+
: 'empty';
|
|
770
|
+
this.logger.log(`[StoryRunner] RESPONSE: ${itemCount} items (${itemSummary})`);
|
|
771
|
+
|
|
772
|
+
// Return inventory for use in assertions/steps
|
|
773
|
+
return inventory;
|
|
774
|
+
},
|
|
775
|
+
|
|
776
|
+
/**
|
|
777
|
+
* Execute command as player (not just chat)
|
|
778
|
+
* Runs player commands like /ability, /plugin commands, etc.
|
|
779
|
+
*/
|
|
780
|
+
async execute_player_command(params) {
|
|
781
|
+
const { player, command } = params;
|
|
782
|
+
if (!player) {
|
|
783
|
+
throw new Error('execute_player_command requires "player" parameter');
|
|
784
|
+
}
|
|
785
|
+
if (!command) {
|
|
786
|
+
throw new Error('execute_player_command requires "command" parameter');
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
const bot = this.bots.get(player);
|
|
790
|
+
if (!bot) {
|
|
791
|
+
throw new Error(`Player "${player}" not found`);
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
this.logger.log(`[StoryRunner] ACTION: ${player} execute command: ${command}`);
|
|
795
|
+
bot.chat(command);
|
|
796
|
+
this.logger.log(`[StoryRunner] RESPONSE: Command sent`);
|
|
797
|
+
},
|
|
798
|
+
|
|
799
|
+
/**
|
|
800
|
+
* Get player location
|
|
801
|
+
* Returns player's position (x, y, z)
|
|
802
|
+
*/
|
|
803
|
+
async get_player_location(params) {
|
|
804
|
+
const { player } = params;
|
|
805
|
+
if (!player) {
|
|
806
|
+
throw new Error('get_player_location requires "player" parameter');
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
this.logger.log(`[StoryRunner] ACTION: ${player} getLocation()`);
|
|
810
|
+
|
|
811
|
+
const bot = this.bots.get(player);
|
|
812
|
+
if (!bot) {
|
|
813
|
+
throw new Error(`Player "${player}" not found`);
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
const position = {
|
|
817
|
+
x: bot.entity.position.x,
|
|
818
|
+
y: bot.entity.position.y,
|
|
819
|
+
z: bot.entity.position.z,
|
|
820
|
+
yaw: bot.entity.yaw,
|
|
821
|
+
pitch: bot.entity.pitch
|
|
822
|
+
};
|
|
823
|
+
|
|
824
|
+
this.logger.log(`[StoryRunner] RESPONSE: x=${position.x.toFixed(2)}, y=${position.y.toFixed(2)}, z=${position.z.toFixed(2)}`);
|
|
825
|
+
return position;
|
|
826
|
+
},
|
|
827
|
+
|
|
828
|
+
/**
|
|
829
|
+
* Get entity location
|
|
830
|
+
* Returns specific entity's position from the entity list
|
|
831
|
+
*/
|
|
832
|
+
async get_entity_location(params) {
|
|
833
|
+
const { player, entity_name } = params;
|
|
834
|
+
if (!player) {
|
|
835
|
+
throw new Error('get_entity_location requires "player" parameter');
|
|
836
|
+
}
|
|
837
|
+
if (!entity_name) {
|
|
838
|
+
throw new Error('get_entity_location requires "entity_name" parameter');
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
const backend = this.backends.players.get(player);
|
|
842
|
+
if (!backend) {
|
|
843
|
+
throw new Error(`Player "${player}" backend not found`);
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
const entities = await backend.getEntities();
|
|
847
|
+
const entity = entities.find(e =>
|
|
848
|
+
e.name === entity_name ||
|
|
849
|
+
e.customName === entity_name ||
|
|
850
|
+
e.customName?.text === entity_name
|
|
851
|
+
);
|
|
852
|
+
|
|
853
|
+
if (!entity) {
|
|
854
|
+
throw new Error(`Entity "${entity_name}" not found`);
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
const position = {
|
|
858
|
+
x: entity.position.x,
|
|
859
|
+
y: entity.position.y,
|
|
860
|
+
z: entity.position.z
|
|
861
|
+
};
|
|
862
|
+
|
|
863
|
+
this.logger.log(`[StoryRunner] Entity "${entity_name}" location: ${position.x.toFixed(2)}, ${position.y.toFixed(2)}, ${position.z.toFixed(2)}`);
|
|
864
|
+
return position;
|
|
865
|
+
},
|
|
866
|
+
|
|
867
|
+
/**
|
|
868
|
+
* Calculate distance between two positions
|
|
869
|
+
*/
|
|
870
|
+
async calculate_distance(params) {
|
|
871
|
+
const { from, to } = params;
|
|
872
|
+
if (!from || !to) {
|
|
873
|
+
throw new Error('calculate_distance requires "from" and "to" positions');
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
const dx = to.x - from.x;
|
|
877
|
+
const dy = to.y - from.y;
|
|
878
|
+
const dz = to.z - from.z;
|
|
879
|
+
const distance = Math.sqrt(dx * dx + dy * dy + dz * dz);
|
|
880
|
+
|
|
881
|
+
this.logger.log(`[StoryRunner] Distance calculated: ${distance.toFixed(2)} blocks`);
|
|
882
|
+
return distance;
|
|
883
|
+
},
|
|
884
|
+
|
|
885
|
+
/**
|
|
886
|
+
* Stop the server
|
|
887
|
+
*/
|
|
888
|
+
async stop_server(params) {
|
|
889
|
+
await this.backends.rcon.send('say [Pilaf] Server stopping...');
|
|
890
|
+
await this.backends.rcon.disconnect();
|
|
891
|
+
this.backends.rcon = null;
|
|
892
|
+
this.logger.log('[StoryRunner] Server stopped');
|
|
893
|
+
}
|
|
894
|
+
};
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
module.exports = { StoryRunner };
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
// packages/framework/lib/helpers/events.js
|
|
2
|
+
|
|
3
|
+
async function waitForEvents(bot, eventName, count, timeout = 5000) {
|
|
4
|
+
const events = [];
|
|
5
|
+
const handler = (data) => events.push({ type: eventName, data });
|
|
6
|
+
|
|
7
|
+
bot.on(eventName, handler);
|
|
8
|
+
|
|
9
|
+
return new Promise((resolve, reject) => {
|
|
10
|
+
const timer = setTimeout(() => {
|
|
11
|
+
bot.removeListener(eventName, handler);
|
|
12
|
+
if (events.length >= count) {
|
|
13
|
+
resolve(events);
|
|
14
|
+
} else {
|
|
15
|
+
reject(new Error(`Timeout waiting for ${count} ${eventName} events (got ${events.length})`));
|
|
16
|
+
}
|
|
17
|
+
}, timeout);
|
|
18
|
+
|
|
19
|
+
const checkComplete = () => {
|
|
20
|
+
if (events.length >= count) {
|
|
21
|
+
clearTimeout(timer);
|
|
22
|
+
bot.removeListener(eventName, handler);
|
|
23
|
+
resolve(events);
|
|
24
|
+
}
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
bot.on(eventName, checkComplete);
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function captureEvents(bot, eventNames) {
|
|
32
|
+
const captured = [];
|
|
33
|
+
const handlers = {};
|
|
34
|
+
|
|
35
|
+
eventNames.forEach(eventName => {
|
|
36
|
+
const handler = (data) => captured.push({ type: eventName, data });
|
|
37
|
+
handlers[eventName] = handler;
|
|
38
|
+
bot.on(eventName, handler);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
return {
|
|
42
|
+
events: captured,
|
|
43
|
+
release: () => {
|
|
44
|
+
Object.entries(handlers).forEach(([eventName, handler]) => {
|
|
45
|
+
bot.removeListener(eventName, handler);
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
module.exports = {
|
|
52
|
+
waitForEvents,
|
|
53
|
+
captureEvents
|
|
54
|
+
};
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
// packages/framework/lib/helpers/state.js
|
|
2
|
+
|
|
3
|
+
function captureState(data) {
|
|
4
|
+
return {
|
|
5
|
+
timestamp: Date.now(),
|
|
6
|
+
data: JSON.parse(JSON.stringify(data)) // Deep clone
|
|
7
|
+
};
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function compareStates(before, after, description = 'State comparison') {
|
|
11
|
+
const changes = [];
|
|
12
|
+
|
|
13
|
+
const beforeKeys = Object.keys(before.data || {});
|
|
14
|
+
const afterKeys = Object.keys(after.data || {});
|
|
15
|
+
|
|
16
|
+
const allKeys = new Set([...beforeKeys, ...afterKeys]);
|
|
17
|
+
|
|
18
|
+
allKeys.forEach(key => {
|
|
19
|
+
const beforeVal = before.data[key];
|
|
20
|
+
const afterVal = after.data[key];
|
|
21
|
+
|
|
22
|
+
if (JSON.stringify(beforeVal) !== JSON.stringify(afterVal)) {
|
|
23
|
+
changes.push({
|
|
24
|
+
key,
|
|
25
|
+
before: beforeVal,
|
|
26
|
+
after: afterVal
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
return {
|
|
32
|
+
description,
|
|
33
|
+
before,
|
|
34
|
+
after,
|
|
35
|
+
changes,
|
|
36
|
+
hasChanges: changes.length > 0
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
module.exports = {
|
|
41
|
+
captureState,
|
|
42
|
+
compareStates
|
|
43
|
+
};
|
package/lib/index.js
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
// packages/framework/lib/index.js
|
|
2
|
+
const { PilafReporter } = require('./reporters/pilaf-reporter');
|
|
3
|
+
const { StoryRunner } = require('./StoryRunner');
|
|
4
|
+
const { waitForEvents, captureEvents } = require('./helpers/events');
|
|
5
|
+
const { captureState, compareStates } = require('./helpers/state');
|
|
6
|
+
const { toHaveReceivedLightningStrikes } = require('./matchers/game-matchers');
|
|
7
|
+
|
|
8
|
+
const { PilafBackendFactory } = require('@pilaf/backends');
|
|
9
|
+
|
|
10
|
+
// Backend helpers
|
|
11
|
+
const rcon = {
|
|
12
|
+
connect: async (config) => PilafBackendFactory.create('rcon', config)
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
const mineflayer = {
|
|
16
|
+
createBot: async (options) => {
|
|
17
|
+
const backend = await PilafBackendFactory.create('mineflayer', options);
|
|
18
|
+
return backend.createBot(options);
|
|
19
|
+
}
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
// Main pilaf API
|
|
23
|
+
const pilaf = {
|
|
24
|
+
waitForEvents,
|
|
25
|
+
captureEvents,
|
|
26
|
+
captureState,
|
|
27
|
+
compareStates
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
module.exports = {
|
|
31
|
+
// Reporter
|
|
32
|
+
PilafReporter,
|
|
33
|
+
|
|
34
|
+
// Story Runner
|
|
35
|
+
StoryRunner,
|
|
36
|
+
|
|
37
|
+
// Main API
|
|
38
|
+
pilaf,
|
|
39
|
+
rcon,
|
|
40
|
+
mineflayer,
|
|
41
|
+
|
|
42
|
+
// Helpers
|
|
43
|
+
waitForEvents,
|
|
44
|
+
captureEvents,
|
|
45
|
+
captureState,
|
|
46
|
+
compareStates,
|
|
47
|
+
|
|
48
|
+
// Matchers
|
|
49
|
+
toHaveReceivedLightningStrikes
|
|
50
|
+
};
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
// packages/framework/lib/matchers/game-matchers.js
|
|
2
|
+
|
|
3
|
+
function toHaveReceivedLightningStrikes(received, count) {
|
|
4
|
+
const strikes = received.filter(e =>
|
|
5
|
+
e.type === 'entityHurt' && e.data?.damageSource?.type === 'lightning'
|
|
6
|
+
);
|
|
7
|
+
|
|
8
|
+
return {
|
|
9
|
+
pass: strikes.length === count,
|
|
10
|
+
message: () => `expected ${count} lightning strikes, got ${strikes.length}`
|
|
11
|
+
};
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
module.exports = {
|
|
15
|
+
toHaveReceivedLightningStrikes
|
|
16
|
+
};
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
// packages/framework/lib/reporters/pilaf-reporter.js
|
|
2
|
+
const { ReportGenerator } = require('@pilaf/reporting');
|
|
3
|
+
const path = require('path');
|
|
4
|
+
|
|
5
|
+
class PilafReporter {
|
|
6
|
+
constructor(globalConfig, options = {}) {
|
|
7
|
+
this._globalConfig = globalConfig;
|
|
8
|
+
this._options = options;
|
|
9
|
+
this._results = {
|
|
10
|
+
suiteName: options.suiteName || 'Pilaf Tests',
|
|
11
|
+
durationMs: 0,
|
|
12
|
+
passed: true,
|
|
13
|
+
stories: [],
|
|
14
|
+
consoleLogs: []
|
|
15
|
+
};
|
|
16
|
+
this._runStartTime = 0;
|
|
17
|
+
// Track console logs per test file to avoid mixing
|
|
18
|
+
this._logsPerTest = new Map();
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
onRunStart(aggregatedResults, options) {
|
|
22
|
+
this._runStartTime = Date.now();
|
|
23
|
+
this._logsPerTest.clear();
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
onTestResult(test, testResult, aggregatedResult) {
|
|
27
|
+
const testPath = test.path;
|
|
28
|
+
|
|
29
|
+
// Initialize logs array for this test if not exists
|
|
30
|
+
if (!this._logsPerTest.has(testPath)) {
|
|
31
|
+
this._logsPerTest.set(testPath, []);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Capture console logs from this test result
|
|
35
|
+
if (testResult.console && testResult.console.length > 0) {
|
|
36
|
+
for (const logEntry of testResult.console) {
|
|
37
|
+
// Jest console entries have: { message: string, origin: string, type: string }
|
|
38
|
+
const message = logEntry.message || logEntry;
|
|
39
|
+
this._logsPerTest.get(testPath).push({
|
|
40
|
+
timestamp: Date.now(),
|
|
41
|
+
message: typeof message === 'string' ? message : JSON.stringify(message)
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Stories are parsed from console logs in onRunComplete
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
onRunComplete(contexts, aggregatedResults) {
|
|
50
|
+
this._results.durationMs = Date.now() - this._runStartTime;
|
|
51
|
+
|
|
52
|
+
// Parse stories from each test file's logs separately
|
|
53
|
+
const allStories = [];
|
|
54
|
+
const allConsoleLogs = [];
|
|
55
|
+
|
|
56
|
+
for (const [testPath, logs] of this._logsPerTest.entries()) {
|
|
57
|
+
// Parse stories from this test file's logs, including the logs for display
|
|
58
|
+
const stories = this._parseStoriesFromLogs(logs, testPath);
|
|
59
|
+
allStories.push(...stories);
|
|
60
|
+
|
|
61
|
+
// Also collect all console logs for the full log view
|
|
62
|
+
allConsoleLogs.push(...logs);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
this._results.stories = allStories;
|
|
66
|
+
this._results.consoleLogs = allConsoleLogs;
|
|
67
|
+
|
|
68
|
+
if (this._options.outputPath) {
|
|
69
|
+
const generator = new ReportGenerator();
|
|
70
|
+
generator.generate(this._results, this._options.outputPath);
|
|
71
|
+
console.log(`\n[Pilaf] Report generated: ${this._options.outputPath}`);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Parse console logs to build story structure with steps and details
|
|
77
|
+
* @param {Array} logs - Console logs for a single test file
|
|
78
|
+
* @param {string} testPath - Path to the test file
|
|
79
|
+
* @returns {Array} Array of story objects
|
|
80
|
+
*/
|
|
81
|
+
_parseStoriesFromLogs(logs, testPath) {
|
|
82
|
+
const stories = [];
|
|
83
|
+
const storyPattern = /\[StoryRunner\] Starting story:\s*(.+)/;
|
|
84
|
+
const stepPattern = /\[StoryRunner\] Step (\d+)\/(\d+):\s*(.+)/;
|
|
85
|
+
const storyCompletePattern = /\[StoryRunner\] Story (.+) (PASSED|FAILED)/;
|
|
86
|
+
|
|
87
|
+
let currentStory = null;
|
|
88
|
+
let currentStep = null;
|
|
89
|
+
let storyLogs = []; // Track logs for the current story
|
|
90
|
+
|
|
91
|
+
for (const log of logs) {
|
|
92
|
+
// All logs go to the current story's console logs
|
|
93
|
+
if (currentStory) {
|
|
94
|
+
storyLogs.push(log);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Check for story start
|
|
98
|
+
const storyMatch = log.message.match(storyPattern);
|
|
99
|
+
if (storyMatch) {
|
|
100
|
+
currentStory = {
|
|
101
|
+
name: storyMatch[1].trim(),
|
|
102
|
+
file: testPath,
|
|
103
|
+
passedCount: 0,
|
|
104
|
+
failedCount: 0,
|
|
105
|
+
steps: [],
|
|
106
|
+
consoleLogs: [] // Will be populated with logs for this story
|
|
107
|
+
};
|
|
108
|
+
stories.push(currentStory);
|
|
109
|
+
currentStep = null;
|
|
110
|
+
storyLogs = []; // Start collecting logs for this story
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Check for story completion
|
|
115
|
+
const completeMatch = log.message.match(storyCompletePattern);
|
|
116
|
+
if (completeMatch) {
|
|
117
|
+
if (completeMatch[2] === 'PASSED') {
|
|
118
|
+
currentStory.passedCount = currentStory.steps.length;
|
|
119
|
+
currentStory.failedCount = 0;
|
|
120
|
+
} else {
|
|
121
|
+
currentStory.passedCount = 0;
|
|
122
|
+
currentStory.failedCount = currentStory.steps.length;
|
|
123
|
+
}
|
|
124
|
+
// Store the collected logs with this story
|
|
125
|
+
currentStory.consoleLogs = [...storyLogs];
|
|
126
|
+
this._results.passed = this._results.passed && currentStory.passedCount > 0;
|
|
127
|
+
currentStory = null;
|
|
128
|
+
currentStep = null;
|
|
129
|
+
storyLogs = [];
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Only process steps if we have a current story
|
|
134
|
+
if (!currentStory) continue;
|
|
135
|
+
|
|
136
|
+
// Check for step start
|
|
137
|
+
const stepMatch = log.message.match(stepPattern);
|
|
138
|
+
if (stepMatch) {
|
|
139
|
+
const stepNum = parseInt(stepMatch[1], 10);
|
|
140
|
+
const stepName = stepMatch[3].trim();
|
|
141
|
+
|
|
142
|
+
// Determine executor from step name
|
|
143
|
+
let executor = 'ASSERT';
|
|
144
|
+
if (stepName.includes('[RCON]')) {
|
|
145
|
+
executor = 'RCON';
|
|
146
|
+
} else if (stepName.includes('[player:')) {
|
|
147
|
+
const playerMatch = stepName.match(/\[player:\s*(\w+)\]/);
|
|
148
|
+
if (playerMatch) {
|
|
149
|
+
executor = `player: ${playerMatch[1]}`;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// Only add step once (in case of duplicate logs)
|
|
154
|
+
if (!currentStory.steps.find(s => s.name === stepName)) {
|
|
155
|
+
currentStep = {
|
|
156
|
+
name: stepName,
|
|
157
|
+
passed: true,
|
|
158
|
+
durationMs: 0,
|
|
159
|
+
executionContext: { executor },
|
|
160
|
+
details: []
|
|
161
|
+
};
|
|
162
|
+
currentStory.steps.push(currentStep);
|
|
163
|
+
} else {
|
|
164
|
+
// If step already exists, get reference to it
|
|
165
|
+
currentStep = currentStory.steps.find(s => s.name === stepName);
|
|
166
|
+
}
|
|
167
|
+
continue;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// If we have a current step, add non-step, non-story logs as details
|
|
171
|
+
if (currentStep && !stepMatch && !storyMatch && !completeMatch) {
|
|
172
|
+
// Filter out logs that are part of step/story management
|
|
173
|
+
if (!log.message.includes('[StoryRunner] Step') &&
|
|
174
|
+
!log.message.includes('[StoryRunner] Starting story') &&
|
|
175
|
+
!log.message.includes('[StoryRunner] Story') &&
|
|
176
|
+
log.message.includes('[StoryRunner]')) {
|
|
177
|
+
const detailMessage = log.message.replace('[StoryRunner] ', '').trim();
|
|
178
|
+
|
|
179
|
+
// Check if this is an ACTION or RESPONSE line
|
|
180
|
+
if (detailMessage.startsWith('ACTION:')) {
|
|
181
|
+
currentStep.details.push({
|
|
182
|
+
timestamp: log.timestamp,
|
|
183
|
+
type: 'action',
|
|
184
|
+
message: detailMessage.replace('ACTION:', '').trim()
|
|
185
|
+
});
|
|
186
|
+
} else if (detailMessage.startsWith('RESPONSE:')) {
|
|
187
|
+
currentStep.details.push({
|
|
188
|
+
timestamp: log.timestamp,
|
|
189
|
+
type: 'response',
|
|
190
|
+
message: detailMessage.replace('RESPONSE:', '').trim()
|
|
191
|
+
});
|
|
192
|
+
} else {
|
|
193
|
+
// Other detail types (stored results, assertions, etc.)
|
|
194
|
+
currentStep.details.push({
|
|
195
|
+
timestamp: log.timestamp,
|
|
196
|
+
type: 'other',
|
|
197
|
+
message: detailMessage
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
return stories;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
module.exports = PilafReporter;
|
package/package.json
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@pilaf/framework",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"main": "lib/index.js",
|
|
5
|
+
"files": [
|
|
6
|
+
"lib/*.js",
|
|
7
|
+
"lib/**/*.js",
|
|
8
|
+
"!lib/**/*.spec.js",
|
|
9
|
+
"!lib/**/*.test.js",
|
|
10
|
+
"README.md",
|
|
11
|
+
"LICENSE"
|
|
12
|
+
],
|
|
13
|
+
"publishConfig": {
|
|
14
|
+
"access": "public"
|
|
15
|
+
},
|
|
16
|
+
"dependencies": {
|
|
17
|
+
"jest": "^29.7.0",
|
|
18
|
+
"js-yaml": "^4.1.0",
|
|
19
|
+
"@pilaf/backends": "1.0.0",
|
|
20
|
+
"@pilaf/reporting": "1.0.0"
|
|
21
|
+
},
|
|
22
|
+
"devDependencies": {
|
|
23
|
+
"@jest/globals": "^29.7.0"
|
|
24
|
+
},
|
|
25
|
+
"peerDependencies": {
|
|
26
|
+
"jest": ">=29.0.0"
|
|
27
|
+
}
|
|
28
|
+
}
|