Custom Matchers

Specter provides a robust suite of built-in assertions, but as your project grows, you may want to create domain-specific matchers. By extending Playwright's expect object, you can encapsulate complex engine polling logic into clean, readable assertions tailored exactly to your game's mechanics.

The Goal

Suppose we are building a MOBA (like Heroes of the Storm) and we want to assert the current phase of the match. Instead of writing raw variable polling logic in every test:

// Repetitive and verbose
await world.waitForVariable("GameState.MatchPhase", "ObjectivesActive", 10000);

We want to create a clean, custom matcher that reads naturally:

// Clean and domain-specific
await expect(world).toBeInGamePhase('ObjectivesActive');

1. Extending the TypeScript Interface

First, we need to tell TypeScript about our new matcher using module augmentation. Create a matchers.d.ts file in your test directory:

import { SpecterWorld } from '@specter/test';

// Define the custom phases for our MOBA
type GamePhase = 'Drafting' | 'PreGame' | 'Laning' | 'ObjectivesActive' | 'CoreExposed' | 'GameOver';

declare global {
  namespace PlaywrightTest {
    interface Matchers<R> {
      /**
       * Polls the UE5 GameState until the specified match phase is reached.
       */
      toBeInGamePhase(expectedPhase: GamePhase): Promise<R>;
    }
  }
}

2. Implementing the Matcher Logic

Next, we write the actual implementation using expect.extend. We can leverage Specter's built-in polling tools like waitForVariable inside the matcher. Create a setup.ts file:

import { expect, SpecterWorld } from '@specter/test';
import type { GamePhase } from './matchers';

expect.extend({
  async toBeInGamePhase(world: SpecterWorld, expectedPhase: GamePhase) {
    try {
      // Use Specter to poll the engine state
      await world.waitForVariable("GameState.MatchPhase", expectedPhase, 15000);
      
      return {
        message: () => `expected GamePhase not to be '${expectedPhase}'`,
        pass: true,
      };
    } catch (error) {
      return {
        message: () => `expected GamePhase to become '${expectedPhase}' within timeout, but it failed.`,
        pass: false,
      };
    }
  },
});

3. Using Your Custom Matcher

Ensure your setup.ts is included in your Playwright config's setup files. Now, you can use your domain-specific assertion in any test!

import { test, expect } from '@specter/test';

test('Objective spawns correctly during ObjectivesActive phase', async ({ world }) => {
  // Wait for the match to transition out of Laning
  await expect(world).toBeInGamePhase('ObjectivesActive');
  
  // Now verify the objective actor spawned
  const tribute = world.actor('BP_RavenTribute');
  await expect(tribute).toBeVisible();
});