StoryRunner Deep Dive

Internal details of the StoryRunner class that executes test stories.

Overview

The StoryRunner class is responsible for: * Loading and validating story definitions * Managing backend connections (RCON, Mineflayer) * Executing story steps sequentially * Handling variable storage and resolution * Proper teardown and resource cleanup

Located at: packages/framework/lib/StoryRunner.js

Constructor

const runner = new StoryRunner({
  logger: console,      // Optional: custom logger
  reporter: myReporter  // Optional: custom reporter
});

Story Lifecycle

1. Load Story

// From JavaScript object
runner.currentStory = {
  name: 'Test',
  setup: { /* ... */ },
  steps: [ /* ... */ ],
  teardown: { /* ... */ }
};

// Or from YAML file (requires js-yaml)
runner.loadStory('./test-story.yaml');

2. Execute Setup

The executeSetup() method:

  1. Connects RCON backend to server

  2. Waits for server to be ready (via RCON list command)

  3. Creates Mineflayer backends for each player

  4. Connects bot players to the server

async executeSetup(setup) {
  // Connect RCON
  this.backends.rcon = await PilafBackendFactory.create('rcon', rconConfig);

  // Wait for server ready
  await this.waitForServerReady();

  // Create players
  for (const playerConfig of setup.players) {
    await this.createPlayer(playerConfig);
  }
}

3. Execute Steps

Each step is processed by executeStep():

  1. Resolves variable references ({variableName})

  2. Executes the action

  3. Stores result if store_as is specified

  4. Returns success/failure

async executeStep(step) {
  // Resolve variables
  const resolvedParams = this.resolveVariables(step);

  // Execute action
  const result = await this.executeAction(step.action, resolvedParams);

  // Store result
  if (step.store_as) {
    this.variables.set(step.store_as, result);
  }

  return { success: true };
}

4. Execute Teardown

The executeTeardown() method ALWAYS runs:

  1. Disconnects all bot players

  2. Disconnects all player backends

  3. Disconnects RCON backend (prevents Jest hanging)

async executeTeardown(teardown) {
  // Disconnect bots
  for (const [username, bot] of this.bots) {
    await this.backends.players.get(username)?.quitBot(bot);
  }

  // Disconnect backends
  for (const [username, backend] of this.backends.players) {
    await backend.disconnect();
  }

  // ALWAYS disconnect RCON
  await this.backends.rcon?.disconnect();
}

Variable Resolution

Variables are resolved recursively:

// Story stores a value
{ action: 'get_player_location', player: 'p', store_as: 'pos' }

// Later step references it
{ action: 'calculate_distance', from: '{pos}', to: '{new_pos}' }

// Resolution process:
// 1. Check if value matches {variableName} pattern
// 2. Look up variable in storage
// 3. Replace with stored value
// 4. Recursively resolve nested references

Action Handlers

Actions are implemented as methods on the actionHandlers object. Each action logs its execution using the ACTION: and RESPONSE: prefixes:

actionHandlers = {
  async execute_command(params) {
    const { command } = params;
    // Log the action being performed
    this.logger.log(`[StoryRunner] ACTION: RCON ${command}`);
    // Execute the action
    const result = await this.backends.rcon.send(command);
    // Log the response received
    this.logger.log(`[StoryRunner] RESPONSE: ${result.raw}`);
    return result;
  },

  async get_entities(params) {
    const { player } = params;
    this.logger.log(`[StoryRunner] ACTION: getEntities() for ${player}`);
    const backend = this.backends.players.get(player);
    const entities = await backend.getEntities();
    const summary = entities.length > 0
      ? entities.slice(0, 5).map(e => e.name || e.customName || e.id).join(', ')
      : 'none';
    this.logger.log(`[StoryRunner] RESPONSE: Found ${entities.length} entities: ${summary}${entities.length > 5 ? '...' : ''}`);
    return entities;
  },

  // ... more actions
}

Logged Action Types

All actions log information for the HTML report:

  • Server Actions - RCON commands sent and responses received

  • Player Actions - Player commands, chat messages, movement

  • Query Actions - Entity queries, inventory checks, position data

  • Calculation Actions - Distance calculations and results

  • Lifecycle Actions - Login/logout events, respawn handling

  • Control Actions - Wait durations

  • Assertion Actions - Condition checks and results

Each action in the report shows: * → action (yellow) - The action performed * ← response (green) - The response received

Adding a new action:

// Add to actionHandlers object
actionHandlers = {
  // ... existing actions

  async my_custom_action(params) {
    // Log the action
    this.logger.log(`[StoryRunner] ACTION: my_custom_action with ${JSON.stringify(params)}`);
    // Implementation
    const result = await doSomething(params);
    // Log the response
    this.logger.log(`[StoryRunner] RESPONSE: ${JSON.stringify(result)}`);
    return result;
  }
}

Server Readiness Check

The waitForServerReady() method polls the server:

async waitForServerReady() {
  const timeout = 120000;  // 2 minutes
  const interval = 3000;   // Check every 3 seconds

  while (Date.now() - startTime < timeout) {
    try {
      await this.backends.rcon.send('list');
      return;  // Server ready
    } catch (error) {
      await new Promise(resolve => setTimeout(resolve, interval));
    }
  }

  throw new Error('Server did not become ready');
}

Error Handling

  • Setup failures - Story aborts, teardown runs

  • Step failures - Story stops, teardown runs

  • Teardown failures - Logged but don’t prevent cleanup

Resource Management

Critical for preventing Jest hangs:

  • Always disconnect RCON - Even if stop_server: false

  • Quit all bots - Before disconnecting backends

  • Clear all references - Prevent memory leaks

// GOOD - Always disconnect
await this.backends.rcon.disconnect();

// BAD - Only disconnect when stopping
if (teardown.stop_server) {
  await this.backends.rcon.disconnect();
}

Performance Considerations

  • Sequential execution - Steps run one at a time

  • Connection reuse - Backends persist across steps

  • Timeout protection - All async operations have timeouts

  • Variable caching - Stored values kept in memory Map

Testing StoryRunner

const { describe, it, expect } = require('@jest/globals');

describe('StoryRunner', () => {
  it('should execute a simple story', async () => {
    const runner = new StoryRunner();
    const story = {
      name: 'Test',
      setup: {
        server: { type: 'paper', version: '1.21.8' }
      },
      steps: [
        { action: 'execute_command', command: 'version' }
      ],
      teardown: { stop_server: false }
    };

    const result = await runner.execute(story);
    expect(result.success).toBe(true);
  });
});

Reporting and Logging Integration

The StoryRunner integrates with Jest reporters through structured logging.

Log Format

All StoryRunner logs use the [StoryRunner] prefix for identification:

// Story lifecycle logs
this.logger.log(`[StoryRunner] Starting story: ${story.name}`);
this.logger.log(`[StoryRunner] Step ${currentStep}/${totalSteps}: ${step.name}`);

// Action/Response logs
this.logger.log(`[StoryRunner] ACTION: RCON ${command}`);
this.logger.log(`[StoryRunner] RESPONSE: ${result.raw}`);

// Variable storage
this.logger.log(`[StoryRunner] Stored result as: ${store_as}`);

Reporter Capture

The Pilaf Jest reporter captures these logs via testResult.console:

// In pilaf-reporter.js
onTestResult(test, testResult, aggregatedResult) {
  // Capture console logs from this test result
  if (testResult.console && testResult.console.length > 0) {
    for (const logEntry of testResult.console) {
      this._allConsoleLogs.push({
        timestamp: Date.now(),
        message: logEntry.message || logEntry
      });
    }
  }
}

The reporter then parses these logs to: 1. Extract story names and their steps 2. Parse ACTION/RESPONSE pairs for detail views 3. Track pass/fail status per story

Step Execution Context

Each step tracks its execution context for the report:

// Internal tracking (not user-visible)
{
  name: 'Get player inventory',
  passed: true,
  executionContext: {
    executor: 'RCON' | 'player: username' | 'ASSERT'
  },
  details: [
    { type: 'action', message: 'getEntities() for player1' },
    { type: 'response', message: 'Found 3 entities: zombie, sheep...' }
  ]
}

The executor field is determined by: * RCON - Server commands (execute_command) * player: {username} - Player-specific actions * ASSERT - Assertion checks

Next Steps


Back to top

Copyright © 2025 Pilaf Contributors. Open source under the MIT license.

This site uses Just the Docs, a documentation theme for Jekyll.