Testing

Testing plugins without a server

A test loads the plugin's real code — compiled by the same AssemblyScript, against the same API and natives as the server build — into a fake server written in TypeScript, and drives it the way players and the game would. The library is @amxts/core/test-utils, part of the core:

import { 
expect
,
test
} from "bun:test";
import {
setup
} from "@amxts/core/test-utils";
test
("/hp heals a hurt player", async () => {
const
server
= await
setup
(); // the project: its modules, then its plugins
const
player
=
server
.
join
("Alice", {
team
: "CT",
health
: 40 });
player
.
say
("/hp");
expect
(
player
.
chat
).toContain("Alice, your HP: 40");
expect
(
player
.
health
).toBe(100);
}, 60_000); // the first test compiles the plugins

npx amxts test runs it with the rest of your tests; it is bun test underneath, and takes the same options. A plugin is compiled once per run and every setup() gets fresh instances, so tests do not share state.

What was compiled is kept in node_modules/.cache/amxts of the folder the tests run from, so the next run does not compile it again: a plugin is compiled anew only when it, a file it imports, the project's amxts.config.ts or the amxts version changes. AMXTS_TEST_CACHE=0 turns this off; deleting the folder empties it.

The fake server is a stand-in: it answers as AMX Mod X and the amxts module do, but it is not a game server. It runs the plugin's top level, then init, then pluginsLoaded, then configsExecuted, in the server's order.

A native the fake server does not answer stops the test with its name: the plugin called the native "get_user_time", which the fake server does not simulate. The fake server answers the natives @amxts/core and the official modules use; a module that needs more brings them in its test kit, and a test can answer one itself with server.defineNative(name, native).

A project: setup()

setup() puts a project on a new fake server as amxts build lays it out on a real one: the modules its amxts.config.ts lists that its plugins use (or pawn keeps), each after what it requires and with its test kit, then its plugins, then the map starts.

const 
server
= await setup(); // the folder the tests run from
const
server
= await setup({
rootDir
: "playground" }); // another project
const
server
= await setup({
plugins
: ["welcome"] }); // only some of its plugins, in this order
const
server
= await setup({
start
: false }); // load, but do not start the map yet
const
server
= await setup({
maxPlayers
: 10,
cvars
: {
mp_freezetime
: "0" } }); // the server's options, below

A module's own folder, which has no amxts.config.ts, is the module alone — and the modules it requires — with its defaults.

One plugin: loadPlugin()

import { 
loadPlugin
} from "@amxts/core/test-utils";
const
server
= await
loadPlugin
("plugins/hello.ts");
const
both
= await
loadPlugin
(["plugins/a.ts", "plugins/b.ts"]);

loadPlugin loads the plugins it is given, and nothing else, into a new server and starts the map. new FakeServer(options) and server.load("plugins/a.ts") do the same a step at a time; a module package loads by its name — server.load("@you/greeter").

The server

const 
server
= await setup({
map
: "de_inferno", // "de_dust2"
maxPlayers
: 20, // 32
modules
: ["cstrike", "fun"], // what hasModule() finds; all of them
cvars
: {
mp_freezetime
: "5" }, // before the plugin loads
files
: { "addons/amxmodx/configs/myplugin.ini": "speed = 250" }, // the game folder
platform
: "win32", // what platform() says; "linux"
timeZone
: "Asia/Yerevan", // the server's, for Date's local getters; this machine's
});

modules without "reapi" is a server without ReAPI, as plain HLDS is: a player's game events come through Ham Sandwich (server.fireHam), and an event of ReGameDLL's own through the hook that hears it there - a message (server.sendMessage), a line of the game's log (server.gameLog('World triggered "Round_End"')), a fakemeta forward (server.fireForward("FM_EmitSound", [...])) - or, where nothing hears it, has no listener.

Plugins on one server hear each other's forwards and natives, as on a real one.

server.join(name, options)a player connects: client_connect, client_authorized, client_putinserver
server.advance(ms)moves the clock and runs every timer that comes due, in order
await server.responses()waits for the web requests plugins sent with fetch and useFetch and hands each response to its plugin, as the next frame would; see below
server.fire(name, ...args)raises a forward — "plugin_cfg", "client_command", "my_on_round_end" — for handlers and Forward.subscribe() alike
server.fireHook(event, args, { result })runs a hookchain; see below
server.sendMessage(name, args, { player, types })sends a message to a client as the game would, by the game's name and in its order of arguments ("DeathMsg" for "death"): the message listeners read and change it; returns { prevented, args }
server.fireHam(event, entity, args, { result })runs an entity's function through the listeners of its class; see below
server.hooked(event, { post, classname })whether a game event reaches the plugins: false once its last listener is taken off, undefined when nothing listened for it; classname asks about Ham Sandwich's hook of that class
server.hamCallsthe entity functions plugins ran - weapon.deploy() and the rest - in order: { fn, entity, args, hooks }
server.native(name, ...args)calls a plugin's export function native as Pawn would — text, Float, arrays, an out-buffer — and gives back what Pawn reads: the text, the array, the number
server.amxtsNative(name, ...args)calls one of the module's own natives for Pawn — the amxts_*_player_data* family — as a Pawn plugin would; server.playerData is the store itself
server.nativeWithRoom(name, room, args)the same with an out-buffer of room cells (charsmax(out), or an array's size)
server.writeFile(path, text) / server.file(path)the game folder fs and the file natives see, in memory
server.setCvar(name, value) / server.cvar(name)a cvar as the console sets it; change listeners hear it
server.translate(lines, language)dictionary lines as a file writes them, "en" unless said: server.translate({ MYPLUGIN_HELLO: "Hello, %s" }); a file in addons/amxmodx/data/lang is read when a plugin loads it. A player's language is his lang setinfo: alice.info.set("lang", "ru")
server.vault(name)a Storage's nVault, as a Map — fill it before the plugin reads it
server.logwhat console.log and server_print wrote, one line a line
server.serverCommand(line)a line typed in the server console, split as the engine splits it: the server.addServerCommand handlers of it; true if one took it
server.commandswhat server.command() ran
server.forwardsthe forwards sent out, { name, args }
server.players, server.player(id)who is connected
server.timemilliseconds since the map started
server.roundEndsthe rounds a plugin ended (game.endRound, rg_round_end): { status, event, delay, message, sound, trigger }
server.soundssounds played: entity.emitSound from an entity, player.playSound to one player — { entity, sample }
server.userMessagesuser messages sent (player.screen.*, message_begin ... message_end), in order: { name, player, args } — args are the write_* values, a string as text: { name: "ScreenFade", player: 1, args: [2048, 0, 0, 200, 0, 0, 100] }; an effect (effects.*) is "SVC_TEMPENTITY", with dest - 0 everyone, 4 near a point, 8 one player - and origin
server.semiclipMasks, server.semiclipControlledresemiclip's mask per player; whether its rules are taken over - a semiclip.rule is set
server.bartimes, server.rules, server.precachedthe progress bar each player was shown; the game rules' members a plugin set; what server.precache took, in the "precache" event the server raises before "init"
server.touch(toucher, touched)one entity moving into another: the "touch" listeners whose classes match; the answer is 1 when one blocked it
server.answerCvar(player, cvar, value)a client answering player.queryCvar (or query_client_cvar)

A plugin that awaits — an async function, sleep, a promise — runs here as on the server, so a sleep(1000) resumes when server.advance() passes it.

A web request a plugin sends with fetch or useFetch goes out for real, from the machine the tests run on, and its response reaches the plugin on await server.responses(). Point the plugin at a web server the test starts itself (Bun.serve), not at the internet:

const 
web
= Bun.serve({
port
: 0,
fetch
: () => Response.json({
temperature
: 21 }) });
const
server
= await setup();
server
.setCvar("myplugin_weather_url", `http://127.0.0.1:${
web
.port}`);
const
alice
=
server
.join("Alice");
alice
.say("/weather");
await
server
.responses();
expect(
alice
.chat).toContain("21 °C");
web
.stop();

A module's request to an ftp:, ftps: or sftp: address goes out the same way, through the basic-ftp and ssh2-sftp-client packages — install them beside the tests (npm install -D basic-ftp ssh2-sftp-client). The test starts the server in its own process — ftp-srv for FTP and FTPS, ssh2 for SFTP — and a request's file is a file of server.files.

Players

import { 
setup
} from "@amxts/core/test-utils";
const
server
= await
setup
();
const
alice
=
server
.
join
("Alice", {
team
: "CT", // "CT"
health
: 100,
armor
: 0,
alive
: true,
bot
: false,
steamId
: "STEAM_0:0:42", // STEAM_0:0:<id>, BOT for a bot
flags
: "abcu", // users.ini letters; "z"
origin
: [0, 0, 0],
weapons
: ["weapon_knife", "weapon_usp"], // the first in his hands; a knife
});
alice
.
say
("/hp"); // true if a plugin swallowed it
alice
.
sayTeam
("rush b");
alice
.
command
("amx_slap \"Bob\" 5"); // a console command, split as the engine splits it
alice
.
disconnect
({
dropped
: true,
reason
: "Kicked" });

What he was shown, one line a line, with the colour bytes taken out: alice.chat, alice.center, alice.console, alice.hud; every line in order is in alice.messages, and alice.clearMessages() starts again. A chat line no plugin swallowed reaches everyone's chat as Alice: text.

His state: health, armor, frags, deaths, team, alive, origin, muted, items and activeItem (weapons with a kind), ammo (by weapon name), commands (what client_cmd ran in his console).

Entities

A player and a weapon are entities, and so is anything create_entity makes — server.createEntity("info_target"). Any entvar or member reads and writes by its name, with or without the prefix; a float is a number and a vector an array of three:

alice.get("gravity");          // 0.5
alice.set("renderfx", 19);
alice.get("rendercolor");      // [0, 160, 255]
alice.get("m_iHideHUD");       // 32

Hookchains

fireHook runs a chain as the game would: the pre listeners, the game's own function unless one of them stopped it, then the post listeners.

import { 
constant
,
setup
} from "@amxts/core/test-utils";
const
server
= await
setup
();
const
attacker
=
server
.
join
("Alice", {
team
: "CT" });
const
victim
=
server
.
join
("Bob", {
team
: "CT" });
const
hit
=
server
.
fireHook
("takeDamage", [
victim
.
id
, 0,
attacker
.
id
, 30.0,
constant
("DMG_BULLET")], {
result
: 1 });
hit
.
prevented
; // a pre listener called preventDefault() or answered
hit
.
result
; // the chain's answer after every listener
hit
.
args
; // the arguments as listeners left them (event.damage = ...)
server
.
fireHook
("fallDamage", [
victim
.
id
], {
result
: 40 }).
result
; // 20 with a halving post listener

The event is named as game.addEventListener names it, or by ReAPI's short name ("take_damage"). The arguments are the chain's own, in order; a float argument is written as a number. result is what the game's function answers when it runs — what a post listener reads as event.result. The answer comes back typed by the chain: a number, a boolean or text.

fireHam does the same for an event heard on one class of entity ({ classname }): the entity comes first, its class picks the listeners.

const 
knife
=
server
.createEntity("weapon_knife");
server
.fireHam("primaryAttack",
knife
).prevented; // true when a listener blocked it
server
.fireHam("takeDamage", box, [0, attacker.id, 30.0, 2], {
result
: 1 }); // a breakable's damage

Timers

setTimeout and setInterval run on the fake clock and nothing else: server.advance(45_000) runs a 45-second interval once, advance(90_000) twice, and a clearTimeout before that means never.

A module's test kit

A module whose plugin calls natives the fake server does not answer — a menu module's show_menu, a module that calls Pawn plugins back — ships a test kit: a file its package.json names, whose default export adds what the module needs to every fake server it runs on.

"amxts": {
  "module": "src/index.ts",
  "testing": "testing/index.ts"
},
"exports": {
  ".": "./src/index.ts",
  "./testing": "./testing/index.ts"
}
// testing/index.ts
import type { FakePlayer, FakeServer } from "@amxts/core/test-utils";
import { 
defineTestKit
} from "@amxts/core/test-utils";
const
shown
= new
WeakMap
<FakeServer, Map<number, string>>();
export default
defineTestKit
({
install
(
server
) {
const
screens
= new
Map
<number, string>();
shown
.
set
(
server
,
screens
);
server
.
defineNative
("show_menu", (
call
, [
id
,
_keys
,
text
]) => {
screens
.
set
(
id
,
call
.
memory
.
text
(
text
));
return 1; }); }, }); /** What a player's menu shows on this server. */ export function screenOf(
server
: FakeServer,
player
: FakePlayer) {
return
shown
.
get
(
server
)?.
get
(
player
.
id
) ?? null;
}

The kit is installed before the module's plugin loads: by setup(), and by server.load("@you/menus"). A test imports what it gives from the package — import { screenOf } from "@you/menus/testing". server.defineNative(name, native) answers a native on that server; the native gets the call — call.memory reads the plugin's memory, call.plugin is who called — and the cells Pawn pushed. server.messageListeners hears every user message a plugin sends.