fiverunner 1.2.1 → 1.3.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.
@@ -6,7 +6,7 @@ import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "nod
6
6
  import { createServer } from "node:http";
7
7
  var package_default = {
8
8
  name: "fiverunner",
9
- version: "1.2.1",
9
+ version: "1.3.0",
10
10
  description: "Run FiveM resources (Lua, JavaScript and NUI) in your browser and in CI. No GTA V, no server, no restarts.",
11
11
  keywords: [
12
12
  "fivem",
@@ -73755,6 +73755,10 @@ var EventManager = class {
73755
73755
  if (resources === void 0) this.#resources.set(event, [resource]);
73756
73756
  else if (!resources.includes(resource)) resources.unshift(resource);
73757
73757
  }
73758
+ /** The events that resources registered for, in the order they first did, with those resources, newest first. */
73759
+ registered() {
73760
+ return [...this.#resources].map(([event, resources]) => [event, [...resources]]);
73761
+ }
73758
73762
  /**
73759
73763
  * Triggers an event now and returns whether it was not canceled. `source`
73760
73764
  * is "" for events of the process itself, and `target`, when given, is the
@@ -74006,7 +74010,7 @@ function intArgument(object) {
74006
74010
  return Number(object.value);
74007
74011
  }
74008
74012
  /** The arguments of a call, as msgpack-c converts them to a vector, which fails for what is no array. */
74009
- function argumentsOf(object) {
74013
+ function argumentsOf$1(object) {
74010
74014
  if (object.type === "array") return object.items;
74011
74015
  throw new ConversionError("arguments that are no array, which msgpack fails to convert to a vector");
74012
74016
  }
@@ -74339,7 +74343,7 @@ var NIL$3 = { type: "nil" };
74339
74343
  function callback(manager, name, fn) {
74340
74344
  return ext(manager.callbacks.create((args) => {
74341
74345
  try {
74342
- fn(argumentsOf(args));
74346
+ fn(argumentsOf$1(args));
74343
74347
  } catch (error) {
74344
74348
  if (!(error instanceof ConversionError)) throw error;
74345
74349
  manager.output.diagnose(manager.currentResource?.name ?? "", `${name} was given ${error.message}: msgpack throws then, which FiveRunner does not emulate, and it ignores the rest of the call.`);
@@ -98391,7 +98395,7 @@ function streamingNatives(side) {
98391
98395
  hash: 12832350468514893849n,
98392
98396
  result: "char*",
98393
98397
  params: ["Hash modelHash"]
98394
- }, () => "CARNOTFOUND"),
98398
+ }, (ctx, model) => model === 0 || ctx.manager.world.kindOf(model) === EntityType.ped ? "CARNOTFOUND" : (model >>> 0).toString(16).toUpperCase().padStart(8, "0")),
98395
98399
  defineNative({
98396
98400
  name: "SET_MODEL_AS_NO_LONGER_NEEDED",
98397
98401
  hash: 16515533089562745515n,
@@ -101949,58 +101953,6 @@ function createProcess(options, clock, side, name, lua, start, processOptions =
101949
101953
  });
101950
101954
  }
101951
101955
  //#endregion
101952
- //#region src/dev/hosts.ts
101953
- /**
101954
- * The domain of the dev UI, which browsers take for the local machine, as
101955
- * they do every name under localhost. Frames are at names under it, an
101956
- * origin for each host of FiveM's that a client has frames at, so that
101957
- * frames keep their storage to themselves and stay out of the UI, as in
101958
- * FiveM, where each is at an origin of its own: https://cfx-nui-<resource>,
101959
- * or nui://<resource>.
101960
- */
101961
- var DEV_DOMAIN = "fiverunner.localhost";
101962
- new TextEncoder();
101963
- var decoder$2 = new TextDecoder();
101964
- /** What a name of frameHost stands for, or undefined for other names. */
101965
- function parseFrameHost(name) {
101966
- const match = /^(.+)\.c([1-9][0-9]*)\.([a-z0-9]+)\.fiverunner\.localhost$/.exec(name);
101967
- if (match === null) return void 0;
101968
- const [, label = "", number = "", tab = ""] = match;
101969
- const client = Number(number);
101970
- if (/^[a-z0-9_-]+$/.test(label)) return {
101971
- host: label,
101972
- client,
101973
- tab
101974
- };
101975
- const hex = /^((?:[0-9a-f]{2})+)\.x$/.exec(label)?.[1];
101976
- if (hex === void 0) return void 0;
101977
- const bytes = Uint8Array.from(hex.match(/../g), (pair) => parseInt(pair, 16));
101978
- return {
101979
- host: decoder$2.decode(bytes),
101980
- client,
101981
- tab
101982
- };
101983
- }
101984
- //#endregion
101985
- //#region src/dev/remote.ts
101986
- /** Where the dev server serves what the dev UI needs of it. */
101987
- var API = "/__fiverunner/api/";
101988
- new TextDecoder();
101989
- /** The bytes in base 64. */
101990
- function toBase64(bytes) {
101991
- let text = "";
101992
- for (let at = 0; at < bytes.length; at += 32768) text += String.fromCharCode(...bytes.subarray(at, at + 32768));
101993
- return btoa(text);
101994
- }
101995
- /** The bytes that `text` has in base 64. */
101996
- function fromBase64(text) {
101997
- return Uint8Array.from(atob(text), (char) => char.charCodeAt(0));
101998
- }
101999
- //#endregion
102000
- //#region src/dev/routes.ts
102001
- /** Where the dev server serves what the frames of the dev UI run on: its service worker, and the bridge to the UI. */
102002
- var DEV_PATH = "/__fiverunner/";
102003
- //#endregion
102004
101956
  //#region src/node/disk.ts
102005
101957
  /**
102006
101958
  * The files of a directory of the host, as a source of MemoryFileSystems,
@@ -102143,397 +102095,6 @@ function withoutTests(source) {
102143
102095
  };
102144
102096
  }
102145
102097
  //#endregion
102146
- //#region src/node/watch.ts
102147
- /** Milliseconds that the watcher waits for changes to stop before it tells of them. */
102148
- var SETTLE = 150;
102149
- /**
102150
- * Watches the files of the project at `root`, but those of node_modules
102151
- * and of dot directories, and calls `changed` with the paths of those that
102152
- * changed, relative to the root, with slashes, once changes stop for a
102153
- * moment, or with none when the host did not tell which. Gives what stops
102154
- * watching.
102155
- */
102156
- function watchProject(root, changed) {
102157
- let timer;
102158
- let paths = /* @__PURE__ */ new Set();
102159
- const heard = (file) => {
102160
- const parts = (file ?? "").split(/[\\/]/);
102161
- if (parts.some((part) => part === "node_modules" || part.startsWith("."))) return;
102162
- if (file === null) paths = void 0;
102163
- else paths?.add(parts.join("/"));
102164
- clearTimeout(timer);
102165
- timer = setTimeout(() => {
102166
- const told = paths === void 0 ? void 0 : [...paths].sort();
102167
- paths = /* @__PURE__ */ new Set();
102168
- changed(told);
102169
- }, SETTLE);
102170
- };
102171
- let watcher;
102172
- try {
102173
- watcher = watch(root, { recursive: true }, (_, file) => heard(file));
102174
- watcher.on("error", () => {});
102175
- } catch {
102176
- watcher = void 0;
102177
- }
102178
- return () => {
102179
- clearTimeout(timer);
102180
- watcher?.close();
102181
- };
102182
- }
102183
- //#endregion
102184
- //#region src/node/devserver.ts
102185
- /** The port that the dev server listens on unless told otherwise, or the next that is free. */
102186
- var DEFAULT_PORT = 30120;
102187
- /** The types of the files of the dev UI, by their extensions. */
102188
- var TYPES$1 = {
102189
- html: "text/html; charset=utf-8",
102190
- js: "text/javascript; charset=utf-8",
102191
- css: "text/css; charset=utf-8",
102192
- json: "application/json",
102193
- wasm: "application/wasm",
102194
- svg: "image/svg+xml",
102195
- png: "image/png"
102196
- };
102197
- /**
102198
- * Starts the dev server of a project, which serves the dev UI, at
102199
- * fiverunner.localhost, and what it asks of the server: the project and its
102200
- * files, but its test files, cfxlua.wasm, and the HTTP requests of its
102201
- * scripts, which the server sends; and the service worker and the bridge
102202
- * of the origins of frames, at the names under it. It tells the dev UIs as
102203
- * files of the project change. It listens on the local machine only, and
102204
- * answers only requests for the names of the local machine, which keeps
102205
- * pages of the web from reading through it.
102206
- */
102207
- async function startDevServer(options) {
102208
- let project = openProject(options.root);
102209
- if (project === void 0) throw new Error(`${options.root} has no FiveM project`);
102210
- const http = options.http ?? fetchHttpClient;
102211
- const listeners = /* @__PURE__ */ new Set();
102212
- let port = 0;
102213
- const handle = async (request, response) => {
102214
- const name = /^(.*?)(?::(\d+))?$/.exec(request.headers.host ?? "");
102215
- const hostname = (name?.[1] ?? "").toLowerCase();
102216
- const url = new URL(request.url ?? "/", "http://local");
102217
- if (name?.[2] !== String(port)) return reply(response, 403, "FiveRunner serves the local machine only.");
102218
- if (hostname === "localhost" || hostname === "127.0.0.1" || hostname === "[::1]") {
102219
- response.writeHead(302, { Location: `http://${DEV_DOMAIN}:${port}${url.pathname}${url.search}` });
102220
- return response.end();
102221
- }
102222
- if (hostname === "fiverunner.localhost") return ui(request, response, url);
102223
- if (parseFrameHost(hostname) !== void 0) return frames(response, url);
102224
- return reply(response, 403, "FiveRunner serves the local machine only.");
102225
- };
102226
- /** Answers the requests of the dev UI. */
102227
- const ui = async (request, response, url) => {
102228
- const { pathname } = url;
102229
- if (pathname === "/") return asset(response, "index.html");
102230
- if (!pathname.startsWith("/__fiverunner/api/")) {
102231
- if (pathname.startsWith("/__fiverunner/")) return asset(response, pathname.slice(DEV_PATH.length));
102232
- return reply(response, 404, "Not found");
102233
- }
102234
- const what = pathname.slice(API.length);
102235
- if (what === "events" && request.method === "GET") {
102236
- response.writeHead(200, {
102237
- "Content-Type": "text/event-stream",
102238
- "Cache-Control": "no-cache",
102239
- Connection: "keep-alive"
102240
- });
102241
- response.write(": FiveRunner\n\n");
102242
- listeners.add(response);
102243
- request.on("close", () => listeners.delete(response));
102244
- return;
102245
- }
102246
- if (request.headers["X-FiveRunner".toLowerCase()] !== "1") return reply(response, 403, "Forbidden");
102247
- const current = project;
102248
- if (what === "project" && request.method === "GET") {
102249
- const info = {
102250
- name: basename(options.root),
102251
- kind: current.kind,
102252
- mount: current.mount,
102253
- commands: current.commands
102254
- };
102255
- return reply(response, 200, JSON.stringify(info), TYPES$1.json);
102256
- }
102257
- if ((what === "list" || what === "read") && request.method === "GET") {
102258
- const path = url.searchParams.get("path") ?? "";
102259
- try {
102260
- if (!listed(current.visible, path)) return reply(response, 404, "Not found");
102261
- if (what === "list") return reply(response, 200, JSON.stringify(current.visible.list(path)), TYPES$1.json);
102262
- return reply(response, 200, current.visible.read(path), "application/octet-stream");
102263
- } catch {
102264
- return reply(response, 404, "Not found");
102265
- }
102266
- }
102267
- if (what === "cfxlua.wasm" && request.method === "GET") return reply(response, 200, options.cfxlua, TYPES$1.wasm);
102268
- if (what === "http" && request.method === "POST") {
102269
- const proxied = JSON.parse(await text$1(request));
102270
- const result = await http({
102271
- ...proxied,
102272
- body: fromBase64(proxied.body)
102273
- });
102274
- const answered = "code" in result ? result : {
102275
- status: result.status,
102276
- headers: result.headers,
102277
- body: toBase64(result.body)
102278
- };
102279
- return reply(response, 200, JSON.stringify(answered), TYPES$1.json);
102280
- }
102281
- return reply(response, 404, "Not found");
102282
- };
102283
- /** Answers the requests that reach the server at the origins of frames, which their service workers leave to it. */
102284
- const frames = (response, url) => {
102285
- const { pathname } = url;
102286
- if (pathname.startsWith("/__fiverunner/")) {
102287
- const path = pathname.slice(DEV_PATH.length);
102288
- return asset(response, path, path === "sw.js" ? { "Service-Worker-Allowed": "/" } : {});
102289
- }
102290
- return reply(response, 503, "This frame of FiveRunner's NUI loads through its dev UI.");
102291
- };
102292
- /** Serves a file of the dev UI, by a path of a URL, which has no dot parts and stays in the assets. */
102293
- const asset = (response, path, headers = {}) => {
102294
- let bytes;
102295
- try {
102296
- bytes = readFileSync(join(options.assets, path));
102297
- } catch {
102298
- return reply(response, 404, "Not found");
102299
- }
102300
- const type = TYPES$1[path.slice(path.lastIndexOf(".") + 1)] ?? "application/octet-stream";
102301
- return reply(response, 200, bytes, type, headers);
102302
- };
102303
- const server = createServer((request, response) => {
102304
- handle(request, response).catch((error) => {
102305
- if (!response.headersSent) reply(response, 500, String(error));
102306
- else response.end();
102307
- });
102308
- });
102309
- port = await listen(server, options.port ?? 30120);
102310
- const stopWatching = watchProject(options.root, (paths) => {
102311
- project = openProject(options.root) ?? project;
102312
- const data = JSON.stringify(paths ?? null);
102313
- for (const listener of listeners) listener.write(`event: change
102314
- data: ${data}
102315
-
102316
- `);
102317
- options.onChange?.();
102318
- });
102319
- return {
102320
- url: `http://${DEV_DOMAIN}:${port}/`,
102321
- port,
102322
- close: () => {
102323
- stopWatching();
102324
- for (const listener of listeners) listener.end();
102325
- return new Promise((resolve) => server.close(() => resolve()));
102326
- }
102327
- };
102328
- }
102329
- /**
102330
- * Whether the directories of `source` list `path`, and those it is in, as
102331
- * sources list what they read: which no path does with test files, or with
102332
- * parts that are empty, dots, or drives, which would lead out of the
102333
- * project. Parts of paths that are files fail to list.
102334
- */
102335
- function listed(source, path) {
102336
- if (path === "") return true;
102337
- let directory = "";
102338
- for (const part of path.split("/")) {
102339
- if (!source.list(directory).some((entry) => entry.name === part)) return false;
102340
- directory = directory === "" ? part : `${directory}/${part}`;
102341
- }
102342
- return true;
102343
- }
102344
- /** Answers with `status`, `body`, and the type, as what the dev UI gets is never cached. */
102345
- function reply(response, status, body, type = "text/plain; charset=utf-8", headers = {}) {
102346
- response.writeHead(status, {
102347
- "Content-Type": type,
102348
- "Cache-Control": "no-cache",
102349
- ...headers
102350
- });
102351
- response.end(body);
102352
- }
102353
- /** The body of a request, as text. */
102354
- async function text$1(request) {
102355
- const chunks = [];
102356
- for await (const chunk of request) chunks.push(chunk);
102357
- return Buffer.concat(chunks).toString("utf8");
102358
- }
102359
- /** Listens on `port` of the local machine, or on the next ports while those are taken, and resolves with the port. */
102360
- function listen(server, port) {
102361
- return new Promise((resolve, reject) => {
102362
- const attempt = (at) => {
102363
- const failed = (error) => {
102364
- server.off("listening", listening);
102365
- if (error.code === "EADDRINUSE" && at !== 0 && at < port + 20) attempt(at + 1);
102366
- else reject(error);
102367
- };
102368
- const listening = () => {
102369
- server.off("error", failed);
102370
- resolve(server.address().port);
102371
- };
102372
- server.once("error", failed);
102373
- server.once("listening", listening);
102374
- server.listen(at, "127.0.0.1");
102375
- };
102376
- attempt(port);
102377
- });
102378
- }
102379
- //#endregion
102380
- //#region src/cli/dev.ts
102381
- var USAGE$3 = `Usage: fiverunner dev [options]
102382
-
102383
- Runs the project in this directory in the dev UI, in the browser: its
102384
- server, with its console and its resources, and players that connect to
102385
- it, each with a screen that shows the frames of its NUI. The dev UI
102386
- restarts the resources whose files change. It needs a browser that takes the
102387
- names under localhost for the local machine, as Chrome, Edge and Firefox do.
102388
-
102389
- Options:
102390
- -p, --port <port> Listen on this port, or the next free one (${DEFAULT_PORT} by default)
102391
- -h, --help Show this help
102392
- `;
102393
- /**
102394
- * Runs the dev server of the project in the directory that the tool runs
102395
- * in until the tool is interrupted, and returns the exit code: 0 once it
102396
- * stopped, and 2 if the arguments make no sense to it, or there is no
102397
- * project.
102398
- */
102399
- async function dev(args, host) {
102400
- if (args.length === 1 && (args[0] === "-h" || args[0] === "--help")) {
102401
- host.stdout(USAGE$3);
102402
- return 0;
102403
- }
102404
- const fail = (why) => {
102405
- host.stderr(`fiverunner: ${why}\n\n${USAGE$3}`);
102406
- return 2;
102407
- };
102408
- let port = DEFAULT_PORT;
102409
- for (let at = 0; at < args.length; at++) {
102410
- const arg = args[at];
102411
- if (arg !== "-p" && arg !== "--port") return fail(`unknown argument ${arg}`);
102412
- const value = args[++at];
102413
- if (value === void 0 || !/^\d{1,5}$/.test(value) || Number(value) > 65535) return fail(`${arg} needs a port, from 0 to 65535`);
102414
- port = Number(value);
102415
- }
102416
- const project = openProject(host.cwd);
102417
- if (project === void 0) {
102418
- host.stderr("fiverunner: there is no FiveM project here: a resource has an fxmanifest.lua or a __resource.lua, and a server a resources directory\n");
102419
- return 2;
102420
- }
102421
- if (!existsSync(join(host.assets, "index.html"))) {
102422
- host.stderr(`fiverunner: the dev UI is not built: there is no ${join(host.assets, "index.html")}\n`);
102423
- return 2;
102424
- }
102425
- const server = await startDevServer({
102426
- root: host.cwd,
102427
- assets: host.assets,
102428
- cfxlua: host.wasm(),
102429
- port,
102430
- onChange: () => host.stdout("Files changed: the dev UI restarts the resources they are in\n")
102431
- });
102432
- const name = project.kind === "resource" ? `the resource ${project.name}` : "the server";
102433
- host.stdout(`FiveRunner runs ${name} at ${server.url}\nPress Ctrl+C to stop\n`);
102434
- await host.interrupted();
102435
- await server.close();
102436
- return 0;
102437
- }
102438
- //#endregion
102439
- //#region src/cli/types.lua?raw
102440
- var types_default = "---@meta\n\n-- What the tests of FiveRunner have, for the Lua language server:\n-- https://github.com/DariusHQ/fiverunner/blob/main/docs/testing.md\n\n---@class Identity\n---@field name? string\n---@field guid? string\n---@field endpoint? string\n---@field identifiers? string[]\n---@field tokens? string[]\n---@field card? table What the player answers the adaptive cards of deferrals with\n\n---@class Player\n---@field id integer The id of the player on the server, its source\n---@field name string\nlocal Player = {}\n\n--- Runs Lua code in the client of the player, with `...` as its arguments, and returns what it returns.\n---@param code string\n---@param ... any\n---@return any ...\nfunction Player:run(code, ...) end\n\n--- Triggers an event on the server from the client, and returns once the server got it.\n---@param event string\n---@param ... any\nfunction Player:emit(event, ...) end\n\n--- The arguments of each `event` that the client got from the server, in the order they came.\n---@param event string\n---@return any[][]\nfunction Player:received(event) end\n\n--- What the client printed, line by line, as `[channel] text`, without colors.\n---@return string[]\nfunction Player:output() end\n\n--- Disconnects the player, and returns once the server dropped it.\n---@param reason? string\nfunction Player:disconnect(reason) end\n\n--- What the client sent the NUI frame of `resource` with SendNUIMessage, in the order it sent them.\n---@param resource string\n---@return table[]\nfunction Player:nuiMessages(resource) end\n\n--- Calls a NUI callback, as the page of the resource does, and returns what it answered, and the status.\n---@param resource string\n---@param name string\n---@param data? table\n---@return any answer\n---@return integer status\nfunction Player:nuiCallback(resource, name, data) end\n\n--- Whether NUI has the focus on the client, and whether it shows the cursor.\n---@return boolean focused\n---@return boolean cursor\nfunction Player:nuiFocus() end\n\n--- Has a key of the keyboard of the client, such as `E` or `LMENU`, go down.\n---@param key string\nfunction Player:keyDown(key) end\n\n--- Has a key of the keyboard of the client go up.\n---@param key string\nfunction Player:keyUp(key) end\n\n--- Has a key go down, and up a frame later.\n---@param key string\nfunction Player:press(key) end\n\n--- Mocks a native on the client, for the rest of the test.\n---@param name string\n---@param fn function\nfunction Player:mock(name, fn) end\n\n---@class Expectation\n---@field never Expectation Negates the matchers\n---@field toBe fun(expected: any)\n---@field toEqual fun(expected: any)\n---@field toBeTruthy fun()\n---@field toBeFalsy fun()\n---@field toBeNil fun()\n---@field toBeGreaterThan fun(n: number)\n---@field toBeGreaterThanOrEqual fun(n: number)\n---@field toBeLessThan fun(n: number)\n---@field toBeLessThanOrEqual fun(n: number)\n---@field toBeCloseTo fun(n: number, digits?: integer)\n---@field toContain fun(item: any)\n---@field toContainEqual fun(item: any)\n---@field toHaveLength fun(n: integer)\n---@field toMatch fun(pattern: string)\n---@field toThrow fun(text?: string)\n\n---@class fiverunnerlib\nfiverunner = {}\n\n--- Connects a player, and returns it once its client started the resources of the server, or nil and why the server refused it.\n---@param identity? Identity\n---@return Player? player\n---@return string? reason\nfunction fiverunner.connect(identity) end\n\n--- What the server printed, line by line, as `[channel] text`, without colors.\n---@return string[]\nfunction fiverunner.output() end\n\n--- Calls `check` at each tick until it returns something truthy, and returns that.\n---@generic T\n---@param check fun(): T\n---@param timeout? integer In milliseconds\n---@return T\nfunction fiverunner.waitFor(check, timeout) end\n\n--- Has `fn` run in place of a native on the server, for every resource, for the rest of the test.\n---@param name string\n---@param fn function\nfunction fiverunner.mock(name, fn) end\n\n--- Groups tests.\n---@param name string\n---@param fn fun()\nfunction describe(name, fn) end\n\n--- Adds a test, which fails if it does not end within `timeout` milliseconds: 5000 unless given.\n---@param name string\n---@param fn fun()\n---@param timeout? integer\nfunction it(name, fn, timeout) end\n\n--- Adds a test.\n---@param name string\n---@param fn fun()\n---@param timeout? integer\nfunction test(name, fn, timeout) end\n\n--- Adds a hook that runs before each test of the describe it is in, or of the file.\n---@param fn fun()\nfunction beforeEach(fn) end\n\n--- Adds a hook that runs after each test of the describe it is in, or of the file.\n---@param fn fun()\nfunction afterEach(fn) end\n\n--- Checks a value with a matcher, which is called with a dot.\n---@param value any\n---@return Expectation\nfunction expect(value) end\n";
102441
- //#endregion
102442
- //#region src/cli/init.ts
102443
- var USAGE$2 = `Usage: fiverunner init
102444
-
102445
- Adds tests to the project in this directory: a test file, which checks
102446
- that the project starts without script errors, what the Lua language
102447
- server needs to know the functions of tests, and, at the root of a git
102448
- repository, a GitHub Actions workflow that runs the tests on each push.
102449
- It writes over nothing that is there.
102450
-
102451
- Options:
102452
- -h, --help Show this help
102453
- `;
102454
- /** The workflow of GitHub Actions that runs the tests of the project on each push. */
102455
- var WORKFLOW = `name: Tests
102456
-
102457
- on: [push, pull_request]
102458
-
102459
- jobs:
102460
- test:
102461
- runs-on: ubuntu-latest
102462
- steps:
102463
- - uses: actions/checkout@v7
102464
- - uses: DariusHQ/fiverunner-action@v1
102465
- `;
102466
- /** Where the types of tests go, which the settings of the Lua language server name. */
102467
- var TYPES = ".fiverunner/fiverunner.lua";
102468
- var SETTINGS = `${JSON.stringify({
102469
- "runtime.version": "Lua 5.4",
102470
- "workspace.library": [".fiverunner"]
102471
- }, null, 2)}\n`;
102472
- /** A test file of `subject`, a Lua expression of the name of the tests, whose test checks the start of the project, on the server and on the client of a player, and what `checks` says. */
102473
- function testFile(subject, checks) {
102474
- return `-- The tests of the project, which \`npx fiverunner test\` runs:
102475
- -- https://github.com/DariusHQ/fiverunner/blob/main/docs/testing.md
102476
-
102477
- local function errors(output)
102478
- local found = {}
102479
- for _, line in ipairs(output) do
102480
- if line:find('SCRIPT ERROR', 1, true) or line:find('Failed to load script', 1, true) then
102481
- found[#found + 1] = line
102482
- end
102483
- end
102484
- return found
102485
- end
102486
-
102487
- describe(${subject}, function()
102488
- it('starts without script errors', function()
102489
- ${checks} -- Players reach servers once they started, which takes the big ones seconds.
102490
- while GetGameTimer() < 3000 do Wait(100) end
102491
- local player, refused = fiverunner.connect({ name = 'Ann' })
102492
- expect(refused).toBeNil()
102493
- expect(errors(fiverunner.output())).toEqual({})
102494
- expect(errors(player:output())).toEqual({})
102495
- end, 120000)
102496
- end)
102497
- `;
102498
- }
102499
- /**
102500
- * Adds a test file and a workflow to the project in the directory that
102501
- * the tool runs in, and returns the exit code: 0 once it did, and 2 if the
102502
- * arguments make no sense to it, or there is no project.
102503
- */
102504
- function init(args, host) {
102505
- if (args.length === 1 && (args[0] === "-h" || args[0] === "--help")) {
102506
- host.stdout(USAGE$2);
102507
- return 0;
102508
- }
102509
- if (args.length > 0) {
102510
- host.stderr(`fiverunner: unknown argument ${args[0]}\n\n${USAGE$2}`);
102511
- return 2;
102512
- }
102513
- const project = openProject(host.cwd);
102514
- if (project === void 0) {
102515
- host.stderr("fiverunner: there is no FiveM project here: a resource has an fxmanifest.lua or a __resource.lua, and a server a resources directory\n");
102516
- return 2;
102517
- }
102518
- const files = project.kind === "resource" ? [[`tests/${project.name}.test.lua`, testFile(JSON.stringify(project.name), ` expect(GetResourceState(${JSON.stringify(project.name)})).toBe('started')\n`)]] : [["tests/server.test.lua", testFile("'the server'", "")]];
102519
- files.push([TYPES, types_default], [".luarc.json", SETTINGS]);
102520
- const repository = existsSync(join(host.cwd, ".git"));
102521
- if (repository) files.push([".github/workflows/fiverunner.yml", WORKFLOW]);
102522
- for (const [path, text] of files) {
102523
- const at = join(host.cwd, path);
102524
- if (existsSync(at)) {
102525
- host.stdout(`Kept ${path}, which is there\n`);
102526
- continue;
102527
- }
102528
- mkdirSync(dirname(at), { recursive: true });
102529
- writeFileSync(at, text);
102530
- host.stdout(`Wrote ${path}\n`);
102531
- }
102532
- if (!repository) host.stdout("Wrote no GitHub workflow, as GitHub looks for them at the root of a git repository, which this is not\n");
102533
- host.stdout("Run the tests with npx fiverunner test\n");
102534
- return 0;
102535
- }
102536
- //#endregion
102537
102098
  //#region src/runner/coverage.ts
102538
102099
  /** The chunks of the scripts of resources, as ScriptRuntime names them: @@resource/path. */
102539
102100
  var SCRIPT_CHUNK = /^@@([^/:]+)\/(.+)$/;
@@ -102603,7 +102164,7 @@ var Coverage = class {
102603
102164
  var client_default = "--[[\n FiveRunner's client helper: the client script of the resource that runs\n the tests, which tells the harness once the client started the resources\n of the server, and runs the code that tests give the player.\n]]\n\nlocal load, pcall, tostring = load, pcall, tostring\nlocal table_pack, table_unpack = table.pack, table.unpack\n\n-- Runs code that a test gave the player, with the arguments it gave, and\n-- gives the harness back what the code returned or raised. FiveM runs each\n-- handler of an event in a thread of its own, so the code may wait.\nRegisterNetEvent('fiverunner:run', function(request, code, args)\n local chunk, problem = load(code, '=(run)', 't')\n if chunk == nil then\n TriggerServerEvent('fiverunner:result', request, false, problem)\n return\n end\n local results = table_pack(pcall(chunk, table_unpack(args, 1, args.n)))\n if results[1] then\n TriggerServerEvent('fiverunner:result', request, true, table_pack(table_unpack(results, 2, results.n)))\n else\n local shown, message = pcall(tostring, results[2])\n TriggerServerEvent('fiverunner:result', request, false, shown and message or '(error object is not a string)')\n end\nend)\n\n-- The resource that runs the tests starts after those of the project.\nTriggerServerEvent('fiverunner:ready')\n";
102604
102165
  //#endregion
102605
102166
  //#region src/runner/lua/harness.lua?raw
102606
- var harness_default = "--[[\n FiveRunner's test harness: the server script of the resource that runs\n the tests of a test file, each in a server of its own.\n\n It loads the test file that the runner names, as FiveM loads the server\n scripts of a resource, and collects the tests that the file adds with\n describe and it. Then it either tells the runner what they are, or runs\n the one that the runner asks for, in a thread of its own, between the\n beforeEach and afterEach hooks of the describes that the test is in,\n and tells the runner what failed.\n\n expect checks the values that tests give it, and fails the test with\n the first that is not as expected. fiverunner connects players, whose\n clients run the client script of the resource, which runs the code\n that tests give them.\n]]\n\nlocal type, error, tostring = type, error, tostring\nlocal pcall, xpcall, next, ipairs = pcall, xpcall, next, ipairs\nlocal rawequal, rawget, rawlen = rawequal, rawget, rawlen\nlocal setmetatable = setmetatable\nlocal string_format, string_find, string_gsub = string.format, string.find, string.gsub\nlocal table_concat, table_insert, table_sort = table.concat, table.insert, table.sort\nlocal table_pack, table_unpack = table.pack, table.unpack\nlocal math_type, math_abs, math_huge = math.type, math.abs, math.huge\nlocal debug_getinfo = debug.getinfo\nlocal msgpack_pack = msgpack.pack\n\nlocal call = FiverunnerCall\nlocal resource = GetCurrentResourceName()\n\n-- Asks the runner for something, with arguments that msgpack packs.\nlocal function ask(method, args)\n return call(method, msgpack_pack(args or {}))\nend\n\n--[[ Errors ]]\n\nlocal HARNESS = debug_getinfo(1, 'S').source\n\n-- Raises an error at the first call outside the harness, as Lua's own\n-- functions raise theirs where they were called.\nlocal function raise(message)\n local level = 2\n while true do\n local info = debug_getinfo(level, 'S')\n if info == nil or info.source ~= HARNESS then\n break\n end\n level = level + 1\n end\n error(message, level)\nend\n\n-- Checks that argument `index` of the function `name` is of the type\n-- `expected`, as Lua's own functions check theirs.\nlocal function checkArgument(name, index, value, expected)\n if type(value) ~= expected then\n raise(string_format(\"bad argument #%d to '%s' (%s expected, got %s)\", index, name, expected, type(value)))\n end\nend\n\n-- What expect fails tests with, whose message tostring gives.\nlocal Failure = {}\n\nfunction Failure.__tostring(failure)\n return failure.message\nend\n\n-- The message of an error, as tostring gives it.\nlocal function messageOf(err)\n local ok, message = pcall(tostring, err)\n return ok and message or '(error object is not a string)'\nend\n\n--[[ Collecting tests ]]\n\n-- The describes form a tree, whose root is the file, and each test is in one.\nlocal root = { beforeEach = {}, afterEach = {} }\nlocal suite = root\nlocal tests = {}\nlocal loading = true\n\n-- Checks that a test, a describe or a hook is added as the test file loads.\nlocal function checkLoading(name)\n if not loading then\n raise(string_format('%s adds to the tests of the file only as it loads', name))\n end\nend\n\nlocal function addSuite(name, mode, title, fn)\n checkArgument(name, 1, title, 'string')\n checkArgument(name, 2, fn, 'function')\n checkLoading(name)\n local parent = suite\n suite = { name = title, parent = parent, mode = mode, beforeEach = {}, afterEach = {} }\n fn()\n suite = parent\nend\n\nlocal function addTest(name, mode, title, fn, timeout)\n checkArgument(name, 1, title, 'string')\n checkArgument(name, 2, fn, 'function')\n if timeout ~= nil and not (type(timeout) == 'number' and timeout > 0 and timeout < math_huge) then\n local got = type(timeout) == 'number' and tostring(timeout) or type(timeout)\n raise(string_format(\"bad argument #3 to '%s' (milliseconds expected, got %s)\", name, got))\n end\n checkLoading(name)\n tests[#tests + 1] = { name = title, suite = suite, mode = mode, fn = fn, timeout = timeout }\nend\n\n-- describe and it, which skip and only mark: tests that are skipped do not\n-- run, and when a file marks some with only, its other tests do not either.\nlocal function entry(name, add)\n return setmetatable({\n skip = function(...)\n add(name .. '.skip', 'skip', ...)\n end,\n only = function(...)\n add(name .. '.only', 'only', ...)\n end,\n }, {\n __call = function(_, ...)\n add(name, 'run', ...)\n end,\n })\nend\n\ndescribe = entry('describe', addSuite)\nit = entry('it', addTest)\ntest = entry('test', addTest)\n\n-- beforeEach and afterEach, which add hooks to the describe they are in.\nlocal function hook(name)\n return function(fn)\n checkArgument(name, 1, fn, 'function')\n checkLoading(name)\n local hooks = suite[name]\n hooks[#hooks + 1] = fn\n end\nend\n\nbeforeEach = hook('beforeEach')\nafterEach = hook('afterEach')\n\n-- The names of the describes that a test is in, and its own.\nlocal function namesOf(test)\n local names = { test.name }\n local at = test.suite\n while at ~= root do\n table_insert(names, 1, at.name)\n at = at.parent\n end\n return names\nend\n\n-- How a test runs: skip if it or a describe it is in is skipped, and else\n-- only if it or one of them is marked only.\nlocal function modeOf(test)\n local skip, only = test.mode == 'skip', test.mode == 'only'\n local at = test.suite\n while at ~= root do\n skip = skip or at.mode == 'skip'\n only = only or at.mode == 'only'\n at = at.parent\n end\n return skip and 'skip' or only and 'only' or 'run'\nend\n\n--[[ Showing values ]]\n\nlocal KEYWORDS = {}\nfor keyword in ('and break do else elseif end false for function goto if in local nil not or repeat return then true until while'):gmatch('%a+') do\n KEYWORDS[keyword] = true\nend\n\n-- Shows no more than this many entries of a table, and tables no deeper than this.\nlocal SHOWN_ENTRIES = 20\nlocal SHOWN_DEPTH = 3\n\n-- Orders the keys of tables to show them: by type, then numbers by value\n-- and the others as tostring gives them.\nlocal function before(a, b)\n local ta, tb = type(a), type(b)\n if ta ~= tb then\n return ta < tb\n end\n if ta == 'number' then\n return a < b\n end\n return tostring(a) < tostring(b)\nend\n\n-- Shows a value as Lua code would write it, as far as it can.\nlocal function show(value, depth)\n local kind = type(value)\n if kind == 'string' then\n return (string_gsub(string_format('%q', value), '\\\\\\n', '\\\\n'))\n elseif kind ~= 'table' then\n return tostring(value)\n end\n depth = depth or 0\n if depth >= SHOWN_DEPTH then\n return next(value) == nil and '{}' or '{...}'\n end\n local length = rawlen(value)\n local keys = {}\n for key in next, value do\n if not (math_type(key) == 'integer' and key >= 1 and key <= length) then\n keys[#keys + 1] = key\n end\n end\n if length + #keys == 0 then\n return '{}'\n end\n table_sort(keys, before)\n local parts = {}\n for index = 1, length do\n if #parts == SHOWN_ENTRIES then\n break\n end\n parts[#parts + 1] = show(rawget(value, index), depth + 1)\n end\n for _, key in ipairs(keys) do\n if #parts == SHOWN_ENTRIES then\n break\n end\n local name = key\n if type(key) ~= 'string' or KEYWORDS[key] or not string_find(key, '^[%a_][%w_]*$') then\n name = '[' .. show(key, depth + 1) .. ']'\n end\n parts[#parts + 1] = name .. ' = ' .. show(rawget(value, key), depth + 1)\n end\n if length + #keys > SHOWN_ENTRIES then\n parts[#parts + 1] = '...'\n end\n return '{ ' .. table_concat(parts, ', ') .. ' }'\nend\n\n--[[ Comparing values ]]\n\n-- Whether two values are the same, as toBe compares them, with NaN the\n-- same as NaN.\nlocal function same(a, b)\n return rawequal(a, b) or (a ~= a and b ~= b)\nend\n\n-- Whether two values are equal, as toEqual compares them: tables when\n-- they have the same keys, with equal values, whatever their metatables,\n-- and other values when they are the same.\nlocal function equal(a, b, seen)\n if same(a, b) then\n return true\n end\n if type(a) ~= 'table' or type(b) ~= 'table' then\n return false\n end\n -- Tables that hold themselves are equal as far as they go.\n seen = seen or {}\n local compared = seen[a]\n if compared == nil then\n compared = {}\n seen[a] = compared\n elseif compared[b] then\n return true\n end\n compared[b] = true\n for key, value in next, a do\n if not equal(value, rawget(b, key), seen) then\n return false\n end\n end\n for key in next, b do\n if rawget(a, key) == nil then\n return false\n end\n end\n return true\nend\n\n--[[ Expectations ]]\n\n-- The matchers of expect: each takes the value and the arguments of the\n-- matcher, and returns whether the value matches, with what fails the test\n-- if it does not, and what fails it if it does when negated with never.\nlocal matchers = {}\n\nfunction matchers.toBe(actual, expected)\n local hint = ''\n if type(actual) == 'table' and type(expected) == 'table' and equal(actual, expected) then\n hint = ', the same table: toEqual compares what tables hold'\n end\n return same(actual, expected),\n string_format('expected %s to be %s%s', show(actual), show(expected), hint),\n string_format('expected %s not to be %s', show(actual), show(expected))\nend\n\nfunction matchers.toEqual(actual, expected)\n return equal(actual, expected),\n string_format('expected %s to equal %s', show(actual), show(expected)),\n string_format('expected %s not to equal %s', show(actual), show(expected))\nend\n\nfunction matchers.toBeTruthy(actual)\n return actual ~= nil and actual ~= false,\n string_format('expected %s to be truthy', show(actual)),\n string_format('expected %s not to be truthy', show(actual))\nend\n\nfunction matchers.toBeFalsy(actual)\n return actual == nil or actual == false,\n string_format('expected %s to be falsy', show(actual)),\n string_format('expected %s not to be falsy', show(actual))\nend\n\nfunction matchers.toBeNil(actual)\n return actual == nil,\n string_format('expected %s to be nil', show(actual)),\n 'expected the value not to be nil'\nend\n\n-- Adds a matcher that compares numbers.\nlocal function comparison(name, phrase, compare)\n matchers[name] = function(actual, expected)\n if type(actual) ~= 'number' or type(expected) ~= 'number' then\n raise(string_format('%s compares numbers, not %s and %s', name, type(actual), type(expected)))\n end\n return compare(actual, expected),\n string_format('expected %s to be %s %s', show(actual), phrase, show(expected)),\n string_format('expected %s not to be %s %s', show(actual), phrase, show(expected))\n end\nend\n\ncomparison('toBeGreaterThan', 'greater than', function(a, b)\n return a > b\nend)\ncomparison('toBeGreaterThanOrEqual', 'greater than or equal to', function(a, b)\n return a >= b\nend)\ncomparison('toBeLessThan', 'less than', function(a, b)\n return a < b\nend)\ncomparison('toBeLessThanOrEqual', 'less than or equal to', function(a, b)\n return a <= b\nend)\n\nfunction matchers.toBeCloseTo(actual, expected, digits)\n if type(actual) ~= 'number' or type(expected) ~= 'number' then\n raise(string_format('toBeCloseTo compares numbers, not %s and %s', type(actual), type(expected)))\n end\n digits = digits == nil and 2 or digits\n if type(digits) ~= 'number' then\n raise(string_format('toBeCloseTo takes a number of digits, not %s', type(digits)))\n end\n local within = 10 ^ -digits / 2\n return actual == expected or math_abs(actual - expected) < within,\n string_format('expected %s to be close to %s, within %s', show(actual), show(expected), show(within)),\n string_format('expected %s not to be close to %s, within %s', show(actual), show(expected), show(within))\nend\n\n-- Whether a table has a value that `matches` takes as the item.\nlocal function holds(t, item, matches)\n for _, value in next, t do\n if matches(value, item) then\n return true\n end\n end\n return false\nend\n\nfunction matchers.toContain(actual, item)\n local found\n if type(actual) == 'string' then\n if type(item) ~= 'string' then\n raise(string_format('toContain finds strings in strings, not %s', type(item)))\n end\n found = string_find(actual, item, 1, true) ~= nil\n elseif type(actual) == 'table' then\n found = holds(actual, item, same)\n else\n raise(string_format('toContain finds values in strings and tables, not in %s', type(actual)))\n end\n return found,\n string_format('expected %s to contain %s', show(actual), show(item)),\n string_format('expected %s not to contain %s', show(actual), show(item))\nend\n\nfunction matchers.toContainEqual(actual, item)\n if type(actual) ~= 'table' then\n raise(string_format('toContainEqual finds values in tables, not in %s', type(actual)))\n end\n return holds(actual, item, equal),\n string_format('expected %s to contain a value equal to %s', show(actual), show(item)),\n string_format('expected %s not to contain a value equal to %s', show(actual), show(item))\nend\n\nfunction matchers.toHaveLength(actual, length)\n if type(actual) ~= 'string' and type(actual) ~= 'table' then\n raise(string_format('toHaveLength measures strings and tables, not %s', type(actual)))\n end\n local actualLength = #actual\n return actualLength == length,\n string_format('expected %s to have length %s, not %s', show(actual), show(length), show(actualLength)),\n string_format('expected %s not to have length %s', show(actual), show(length))\nend\n\nfunction matchers.toMatch(actual, pattern)\n if type(actual) ~= 'string' or type(pattern) ~= 'string' then\n raise(string_format('toMatch matches strings with patterns, not %s with %s', type(actual), type(pattern)))\n end\n local ok, found = pcall(string_find, actual, pattern)\n if not ok then\n raise(string_format('toMatch takes Lua patterns: %s', found))\n end\n return found ~= nil,\n string_format('expected %s to match %s', show(actual), show(pattern)),\n string_format('expected %s not to match %s', show(actual), show(pattern))\nend\n\nfunction matchers.toThrow(actual, expected)\n if type(actual) ~= 'function' then\n raise(string_format('toThrow calls functions, not %s', type(actual)))\n end\n if expected ~= nil and type(expected) ~= 'string' then\n raise(string_format('toThrow finds strings in what functions throw, not %s', type(expected)))\n end\n local ok, err = pcall(actual)\n local with = expected == nil and '' or string_format(' an error with %s', show(expected))\n if ok then\n return false, string_format('expected the function to throw%s', with), ''\n end\n local message = messageOf(err)\n return expected == nil or string_find(message, expected, 1, true) ~= nil,\n string_format('expected the function to throw%s, but it threw %s', with, show(message)),\n string_format('expected the function not to throw%s, but it threw %s', with, show(message))\nend\n\n-- Runs a matcher on the value that expect took, and fails the test if the\n-- value does not match, or if it does but `negated` says it should not.\nlocal function check(expectation, name, matcher, actual, negated, ...)\n if rawequal((...), expectation) then\n raise(string_format('call %s with a dot, as in expect(value).%s(...), not with a colon', name, name))\n end\n local matches, message, negatedMessage = matcher(actual, ...)\n if matches == negated then\n error(setmetatable({ message = negated and negatedMessage or message }, Failure))\n end\nend\n\n-- Takes a value to check with the matchers that it gives, or that its\n-- field never gives, which fail when the value matches.\nfunction expect(actual)\n local expectation, negation = {}, {}\n for name, matcher in next, matchers do\n expectation[name] = function(...)\n check(expectation, name, matcher, actual, false, ...)\n end\n negation[name] = function(...)\n check(negation, name, matcher, actual, true, ...)\n end\n end\n expectation.never = negation\n return expectation\nend\n\n--[[ Players ]]\n\nfiverunner = {}\n\n-- The ids of the players whose clients started the resources of the server.\nlocal ready = {}\n-- What the clients of players gave back of the code they ran, by request.\nlocal results = {}\nlocal requests = 0\n\nRegisterNetEvent('fiverunner:ready', function()\n ready[source] = true\nend)\n\nRegisterNetEvent('fiverunner:result', function(request, ok, values)\n results[request] = { ok = ok, values = values }\nend)\n\n-- What players are, by the types of what the identities of players have.\nlocal IDENTITY = { name = 'string', guid = 'string', endpoint = 'string', identifiers = 'table', tokens = 'table', card = 'table' }\n\n-- A player that connected, by its id on the server and its name.\nlocal Player = {}\nPlayer.__index = Player\n\n-- Connects a player, as its identity says: its name, its guid, the\n-- endpoint it connects from, its identifiers and tokens, which are lists\n-- of strings, and what it answers the cards of deferrals with. Returns the\n-- player once its client started the resources of the server, or nil and\n-- why the server refused it.\nfunction fiverunner.connect(identity)\n if identity ~= nil then\n checkArgument('fiverunner.connect', 1, identity, 'table')\n for key, value in next, identity do\n local expected = IDENTITY[key]\n if expected == nil then\n raise(string_format('players have a name, a guid, an endpoint, identifiers, tokens and a card, not %s', show(key)))\n end\n if type(value) ~= expected then\n raise(string_format('the %s of a player is a %s, not a %s', key, expected, type(value)))\n end\n if expected == 'table' and key ~= 'card' then\n for _, item in next, value do\n if type(item) ~= 'string' then\n raise(string_format('the %s of a player are strings, not %s', key, type(item)))\n end\n end\n end\n end\n end\n local answer = identity ~= nil and identity.card ~= nil and json.encode(identity.card) or nil\n local connection = ask('connect', { identity = identity, answer = answer })\n while true do\n local state = ask('connection', connection)\n if state.state == 'rejected' then\n return nil, state.reason\n elseif state.state == 'dropped' then\n return nil, 'the server dropped the player'\n elseif state.state == 'joined' and ready[state.id] then\n return setmetatable({ id = state.id, name = state.name }, Player)\n end\n Wait(0)\n end\nend\n\n-- Runs `code` in the client of the player, as a chunk of its own that\n-- takes `...`, in a thread of its own, and returns what it returns once\n-- the client gave it back. Raises what the code raised.\nfunction Player:run(code, ...)\n checkArgument('run', 1, code, 'string')\n requests = requests + 1\n local request = requests\n TriggerClientEvent('fiverunner:run', self.id, request, code, table_pack(...))\n while results[request] == nil do\n if not DoesPlayerExist(self.id) then\n raise(string_format('player %d left before its client ran the code', self.id))\n end\n Wait(0)\n end\n local result = results[request]\n if not result.ok then\n raise(string_format('the client of player %d raised: %s', self.id, result.values))\n end\n return table_unpack(result.values, 1, result.values.n)\nend\n\n-- Triggers `event` on the server from the client of the player, with\n-- `...`, and returns once the server got it.\nfunction Player:emit(event, ...)\n checkArgument('emit', 1, event, 'string')\n self:run('TriggerServerEvent(...)', event, ...)\nend\n\n-- The arguments of each `event` that the client of the player got from the\n-- network, in the order they came.\nfunction Player:received(event)\n checkArgument('received', 1, event, 'string')\n return ask('received', { id = self.id, event = event })\nend\n\n-- What the client of the player printed on its console, line by line.\nfunction Player:output()\n return ask('output', { id = self.id })\nend\n\n-- Disconnects the client of the player, as players quit, for `reason`,\n-- and returns once the server dropped the player.\nfunction Player:disconnect(reason)\n if reason ~= nil then\n checkArgument('disconnect', 1, reason, 'string')\n end\n ask('disconnect', { id = self.id, reason = reason })\n while DoesPlayerExist(self.id) do\n Wait(0)\n end\nend\n\n-- Has `fn` run in place of the native that the Lua global `name` stands\n-- for, on the server or on the client of a player, for the rest of the\n-- test, with the arguments of the native, for the first of its results to\n-- be what the native returns.\nlocal function mock(id, name, fn)\n checkArgument('mock', 1, name, 'string')\n checkArgument('mock', 2, fn, 'function')\n if not ask('mock', { id = id, name = name, fn = fn }) then\n raise(string_format('there is no native %s to mock', show(name)))\n end\nend\n\nfunction fiverunner.mock(name, fn)\n mock(nil, name, fn)\nend\n\nfunction Player:mock(name, fn)\n mock(self.id, name, fn)\nend\n\n-- What the scripts of the client of the player sent the frame of the NUI\n-- of `resource`, as SendNUIMessage gives it, in the order they sent it.\nfunction Player:nuiMessages(resource)\n checkArgument('nuiMessages', 1, resource, 'string')\n local messages = {}\n for i, text in ipairs(ask('nuiMessages', { id = self.id, resource = resource })) do\n messages[i] = json.decode(text)\n end\n return messages\nend\n\n-- Calls the NUI callback `name` of `resource` on the client of the\n-- player, as the page of the resource does, with `data` as JSON, and\n-- returns what the callback answered once it did, as JSON reads it, or as\n-- it is when it is no JSON, and the status of the answer.\nfunction Player:nuiCallback(resource, name, data)\n checkArgument('nuiCallback', 1, resource, 'string')\n checkArgument('nuiCallback', 2, name, 'string')\n local request = ask('nuiCallback', { id = self.id, resource = resource, name = name, data = json.encode(data or {}) })\n while true do\n local answer = ask('nuiAnswer', request)\n if answer.status ~= nil then\n local ok, value = pcall(json.decode, answer.body)\n if ok then\n return value, answer.status\n end\n return answer.body, answer.status\n end\n Wait(0)\n end\nend\n\n-- Whether NUI has the focus on the client of the player, and whether it\n-- shows the cursor, as SetNuiFocus left them.\nfunction Player:nuiFocus()\n local focus = ask('nuiFocus', { id = self.id })\n return focus.focused, focus.cursor\nend\n\n-- Has the key `key` of the keyboard of the client of the player, by the\n-- name of FiveM's keyboard mapper, such as E or LMENU, go down before the\n-- next frame: it presses the controls of the game that it presses, and\n-- runs the commands that resources bound to it with RegisterKeyMapping.\nfunction Player:keyDown(key)\n checkArgument('keyDown', 1, key, 'string')\n ask('key', { id = self.id, key = key, down = true })\nend\n\n-- Has the key `key` go up before the next frame, which runs -command for\n-- the +command that resources bound to it.\nfunction Player:keyUp(key)\n checkArgument('keyUp', 1, key, 'string')\n ask('key', { id = self.id, key = key, down = false })\nend\n\n-- Presses the key `key`: it goes down, and up a frame later.\nfunction Player:press(key)\n self:keyDown(key)\n Wait(0)\n self:keyUp(key)\nend\n\n-- What the server printed on its console, line by line.\nfunction fiverunner.output()\n return ask('output')\nend\n\n-- Calls `check` at each tick until it returns something truthy, and\n-- returns that, or fails the test once `timeout` milliseconds went by.\nfunction fiverunner.waitFor(check, timeout)\n checkArgument('fiverunner.waitFor', 1, check, 'function')\n if timeout ~= nil then\n checkArgument('fiverunner.waitFor', 2, timeout, 'number')\n end\n local start = GetGameTimer()\n while true do\n local value = check()\n if value then\n return value\n end\n if timeout ~= nil and GetGameTimer() - start >= timeout then\n error(setmetatable({ message = string_format('waitFor gave up after %s ms', show(timeout)) }, Failure))\n end\n Wait(0)\n end\nend\n\n--[[ Running tests ]]\n\nlocal target = ask('target')\nlocal chunkName = string_format('@@%s/%s', resource, target.file)\n\n-- Reports no more than this many lines that failures came through.\nlocal REPORTED_FRAMES = 20\n\n-- What failed, from what was raised: the message, with the lines of the\n-- test file that the stack was at, innermost first.\nlocal function failureOf(err)\n local frames = {}\n local level = 2\n while #frames < REPORTED_FRAMES do\n local info = debug_getinfo(level, 'Sln')\n if info == nil then\n break\n end\n if info.source == chunkName then\n frames[#frames + 1] = { line = info.currentline, name = info.name }\n end\n level = level + 1\n end\n return { message = messageOf(err), frames = frames }\nend\n\n-- Calls `fn`, and returns what failed, if anything did. Lua calls no\n-- handler for errors of memory, and gives the error of the handler if\n-- the handler fails.\nlocal function attempt(fn)\n local failure\n local ok, err = xpcall(fn, function(raised)\n failure = failureOf(raised)\n end)\n if ok then\n return nil\n end\n return failure or { message = messageOf(err), frames = {} }\nend\n\n-- Runs a test with the hooks of the describes it is in: the beforeEach\n-- hooks from the outermost describe in, the test if they did not fail, and\n-- the afterEach hooks from the innermost describe out, in each the last\n-- first. Returns what failed.\nlocal function run(test)\n local failures = {}\n local function note(failure)\n failures[#failures + 1] = failure\n return failure == nil\n end\n local suites = {}\n local at = test.suite\n while at ~= nil do\n table_insert(suites, 1, at)\n at = at.parent\n end\n local ready = true\n for _, inner in ipairs(suites) do\n for _, fn in ipairs(inner.beforeEach) do\n ready = ready and note(attempt(fn))\n end\n end\n if ready then\n note(attempt(test.fn))\n end\n for index = #suites, 1, -1 do\n local hooks = suites[index].afterEach\n for hookIndex = #hooks, 1, -1 do\n note(attempt(hooks[hookIndex]))\n end\n end\n return failures\nend\n\nlocal loadFailure\nlocal chunk, syntaxError = load(LoadResourceFile(resource, target.file), chunkName, 't')\nif chunk == nil then\n loadFailure = { message = syntaxError, frames = {} }\nelse\n loadFailure = attempt(chunk)\nend\nloading = false\n\nif target.test == nil then\n local collected = {}\n for index, entry in ipairs(tests) do\n collected[index] = { names = namesOf(entry), mode = modeOf(entry), timeout = entry.timeout }\n end\n ask('report', { tests = collected, failure = loadFailure })\nelse\n local entry = tests[target.test]\n if loadFailure ~= nil or entry == nil then\n local failure = loadFailure or { message = 'the test file has fewer tests than it had', frames = {} }\n ask('report', { failures = { failure } })\n else\n -- The scripts of the project start their threads before the test runs.\n CreateThread(function()\n local failures = run(entry)\n ask('report', { names = namesOf(entry), failures = failures })\n end)\n end\nend\n";
102167
+ var harness_default = "--[[\n FiveRunner's test harness: the server script of the resource that runs\n the tests of a test file, each in a server of its own.\n\n It loads the test file that the runner names, as FiveM loads the server\n scripts of a resource, and collects the tests that the file adds with\n describe and it. Then it either tells the runner what they are, or runs\n the one that the runner asks for, in a thread of its own, between the\n beforeEach and afterEach hooks of the describes that the test is in,\n and tells the runner what failed.\n\n expect checks the values that tests give it, and fails the test with\n the first that is not as expected. fiverunner connects players, whose\n clients run the client script of the resource, which runs the code\n that tests give them.\n]]\n\nlocal type, error, tostring = type, error, tostring\nlocal pcall, xpcall, next, ipairs = pcall, xpcall, next, ipairs\nlocal rawequal, rawget, rawlen = rawequal, rawget, rawlen\nlocal setmetatable = setmetatable\nlocal string_format, string_find, string_gsub = string.format, string.find, string.gsub\nlocal table_concat, table_insert, table_sort = table.concat, table.insert, table.sort\nlocal table_pack, table_unpack = table.pack, table.unpack\nlocal math_type, math_abs, math_huge = math.type, math.abs, math.huge\nlocal debug_getinfo = debug.getinfo\nlocal msgpack_pack = msgpack.pack\n\nlocal call = FiverunnerCall\nlocal resource = GetCurrentResourceName()\n\n-- Asks the runner for something, with arguments that msgpack packs.\nlocal function ask(method, args)\n return call(method, msgpack_pack(args or {}))\nend\n\n--[[ Errors ]]\n\nlocal HARNESS = debug_getinfo(1, 'S').source\n\n-- Raises an error at the first call outside the harness, as Lua's own\n-- functions raise theirs where they were called.\nlocal function raise(message)\n local level = 2\n while true do\n local info = debug_getinfo(level, 'S')\n if info == nil or info.source ~= HARNESS then\n break\n end\n level = level + 1\n end\n error(message, level)\nend\n\n-- Checks that argument `index` of the function `name` is of the type\n-- `expected`, as Lua's own functions check theirs.\nlocal function checkArgument(name, index, value, expected)\n if type(value) ~= expected then\n raise(string_format(\"bad argument #%d to '%s' (%s expected, got %s)\", index, name, expected, type(value)))\n end\nend\n\n-- What expect fails tests with, whose message tostring gives.\nlocal Failure = {}\n\nfunction Failure.__tostring(failure)\n return failure.message\nend\n\n-- The message of an error, as tostring gives it.\nlocal function messageOf(err)\n local ok, message = pcall(tostring, err)\n return ok and message or '(error object is not a string)'\nend\n\n--[[ Collecting tests ]]\n\n-- The describes form a tree, whose root is the file, and each test is in one.\nlocal root = { beforeEach = {}, afterEach = {} }\nlocal suite = root\nlocal tests = {}\nlocal loading = true\n\n-- Checks that a test, a describe or a hook is added as the test file loads.\nlocal function checkLoading(name)\n if not loading then\n raise(string_format('%s adds to the tests of the file only as it loads', name))\n end\nend\n\nlocal function addSuite(name, mode, title, fn)\n checkArgument(name, 1, title, 'string')\n checkArgument(name, 2, fn, 'function')\n checkLoading(name)\n local parent = suite\n suite = { name = title, parent = parent, mode = mode, beforeEach = {}, afterEach = {} }\n fn()\n suite = parent\nend\n\nlocal function addTest(name, mode, title, fn, timeout)\n checkArgument(name, 1, title, 'string')\n checkArgument(name, 2, fn, 'function')\n if timeout ~= nil and not (type(timeout) == 'number' and timeout > 0 and timeout < math_huge) then\n local got = type(timeout) == 'number' and tostring(timeout) or type(timeout)\n raise(string_format(\"bad argument #3 to '%s' (milliseconds expected, got %s)\", name, got))\n end\n checkLoading(name)\n tests[#tests + 1] = { name = title, suite = suite, mode = mode, fn = fn, timeout = timeout }\nend\n\n-- describe and it, which skip and only mark: tests that are skipped do not\n-- run, and when a file marks some with only, its other tests do not either.\nlocal function entry(name, add)\n return setmetatable({\n skip = function(...)\n add(name .. '.skip', 'skip', ...)\n end,\n only = function(...)\n add(name .. '.only', 'only', ...)\n end,\n }, {\n __call = function(_, ...)\n add(name, 'run', ...)\n end,\n })\nend\n\ndescribe = entry('describe', addSuite)\nit = entry('it', addTest)\ntest = entry('test', addTest)\n\n-- beforeEach and afterEach, which add hooks to the describe they are in.\nlocal function hook(name)\n return function(fn)\n checkArgument(name, 1, fn, 'function')\n checkLoading(name)\n local hooks = suite[name]\n hooks[#hooks + 1] = fn\n end\nend\n\nbeforeEach = hook('beforeEach')\nafterEach = hook('afterEach')\n\n-- The names of the describes that a test is in, and its own.\nlocal function namesOf(test)\n local names = { test.name }\n local at = test.suite\n while at ~= root do\n table_insert(names, 1, at.name)\n at = at.parent\n end\n return names\nend\n\n-- How a test runs: skip if it or a describe it is in is skipped, and else\n-- only if it or one of them is marked only.\nlocal function modeOf(test)\n local skip, only = test.mode == 'skip', test.mode == 'only'\n local at = test.suite\n while at ~= root do\n skip = skip or at.mode == 'skip'\n only = only or at.mode == 'only'\n at = at.parent\n end\n return skip and 'skip' or only and 'only' or 'run'\nend\n\n--[[ Showing values ]]\n\nlocal KEYWORDS = {}\nfor keyword in ('and break do else elseif end false for function goto if in local nil not or repeat return then true until while'):gmatch('%a+') do\n KEYWORDS[keyword] = true\nend\n\n-- Shows no more than this many entries of a table, and tables no deeper than this.\nlocal SHOWN_ENTRIES = 20\nlocal SHOWN_DEPTH = 3\n\n-- Orders the keys of tables to show them: by type, then numbers by value\n-- and the others as tostring gives them.\nlocal function before(a, b)\n local ta, tb = type(a), type(b)\n if ta ~= tb then\n return ta < tb\n end\n if ta == 'number' then\n return a < b\n end\n return tostring(a) < tostring(b)\nend\n\n-- Shows a value as Lua code would write it, as far as it can.\nlocal function show(value, depth)\n local kind = type(value)\n if kind == 'string' then\n return (string_gsub(string_format('%q', value), '\\\\\\n', '\\\\n'))\n elseif kind ~= 'table' then\n return tostring(value)\n end\n depth = depth or 0\n if depth >= SHOWN_DEPTH then\n return next(value) == nil and '{}' or '{...}'\n end\n local length = rawlen(value)\n local keys = {}\n for key in next, value do\n if not (math_type(key) == 'integer' and key >= 1 and key <= length) then\n keys[#keys + 1] = key\n end\n end\n if length + #keys == 0 then\n return '{}'\n end\n table_sort(keys, before)\n local parts = {}\n for index = 1, length do\n if #parts == SHOWN_ENTRIES then\n break\n end\n parts[#parts + 1] = show(rawget(value, index), depth + 1)\n end\n for _, key in ipairs(keys) do\n if #parts == SHOWN_ENTRIES then\n break\n end\n local name = key\n if type(key) ~= 'string' or KEYWORDS[key] or not string_find(key, '^[%a_][%w_]*$') then\n name = '[' .. show(key, depth + 1) .. ']'\n end\n parts[#parts + 1] = name .. ' = ' .. show(rawget(value, key), depth + 1)\n end\n if length + #keys > SHOWN_ENTRIES then\n parts[#parts + 1] = '...'\n end\n return '{ ' .. table_concat(parts, ', ') .. ' }'\nend\n\n--[[ Comparing values ]]\n\n-- Whether two values are the same, as toBe compares them, with NaN the\n-- same as NaN.\nlocal function same(a, b)\n return rawequal(a, b) or (a ~= a and b ~= b)\nend\n\n-- Whether two values are equal, as toEqual compares them: tables when\n-- they have the same keys, with equal values, whatever their metatables,\n-- and other values when they are the same.\nlocal function equal(a, b, seen)\n if same(a, b) then\n return true\n end\n if type(a) ~= 'table' or type(b) ~= 'table' then\n return false\n end\n -- Tables that hold themselves are equal as far as they go.\n seen = seen or {}\n local compared = seen[a]\n if compared == nil then\n compared = {}\n seen[a] = compared\n elseif compared[b] then\n return true\n end\n compared[b] = true\n for key, value in next, a do\n if not equal(value, rawget(b, key), seen) then\n return false\n end\n end\n for key in next, b do\n if rawget(a, key) == nil then\n return false\n end\n end\n return true\nend\n\n--[[ Expectations ]]\n\n-- The matchers of expect: each takes the value and the arguments of the\n-- matcher, and returns whether the value matches, with what fails the test\n-- if it does not, and what fails it if it does when negated with never.\nlocal matchers = {}\n\nfunction matchers.toBe(actual, expected)\n local hint = ''\n if type(actual) == 'table' and type(expected) == 'table' and equal(actual, expected) then\n hint = ', the same table: toEqual compares what tables hold'\n end\n return same(actual, expected),\n string_format('expected %s to be %s%s', show(actual), show(expected), hint),\n string_format('expected %s not to be %s', show(actual), show(expected))\nend\n\nfunction matchers.toEqual(actual, expected)\n return equal(actual, expected),\n string_format('expected %s to equal %s', show(actual), show(expected)),\n string_format('expected %s not to equal %s', show(actual), show(expected))\nend\n\nfunction matchers.toBeTruthy(actual)\n return actual ~= nil and actual ~= false,\n string_format('expected %s to be truthy', show(actual)),\n string_format('expected %s not to be truthy', show(actual))\nend\n\nfunction matchers.toBeFalsy(actual)\n return actual == nil or actual == false,\n string_format('expected %s to be falsy', show(actual)),\n string_format('expected %s not to be falsy', show(actual))\nend\n\nfunction matchers.toBeNil(actual)\n return actual == nil,\n string_format('expected %s to be nil', show(actual)),\n 'expected the value not to be nil'\nend\n\n-- Adds a matcher that compares numbers.\nlocal function comparison(name, phrase, compare)\n matchers[name] = function(actual, expected)\n if type(actual) ~= 'number' or type(expected) ~= 'number' then\n raise(string_format('%s compares numbers, not %s and %s', name, type(actual), type(expected)))\n end\n return compare(actual, expected),\n string_format('expected %s to be %s %s', show(actual), phrase, show(expected)),\n string_format('expected %s not to be %s %s', show(actual), phrase, show(expected))\n end\nend\n\ncomparison('toBeGreaterThan', 'greater than', function(a, b)\n return a > b\nend)\ncomparison('toBeGreaterThanOrEqual', 'greater than or equal to', function(a, b)\n return a >= b\nend)\ncomparison('toBeLessThan', 'less than', function(a, b)\n return a < b\nend)\ncomparison('toBeLessThanOrEqual', 'less than or equal to', function(a, b)\n return a <= b\nend)\n\nfunction matchers.toBeCloseTo(actual, expected, digits)\n if type(actual) ~= 'number' or type(expected) ~= 'number' then\n raise(string_format('toBeCloseTo compares numbers, not %s and %s', type(actual), type(expected)))\n end\n digits = digits == nil and 2 or digits\n if type(digits) ~= 'number' then\n raise(string_format('toBeCloseTo takes a number of digits, not %s', type(digits)))\n end\n local within = 10 ^ -digits / 2\n return actual == expected or math_abs(actual - expected) < within,\n string_format('expected %s to be close to %s, within %s', show(actual), show(expected), show(within)),\n string_format('expected %s not to be close to %s, within %s', show(actual), show(expected), show(within))\nend\n\n-- Whether a table has a value that `matches` takes as the item.\nlocal function holds(t, item, matches)\n for _, value in next, t do\n if matches(value, item) then\n return true\n end\n end\n return false\nend\n\nfunction matchers.toContain(actual, item)\n local found\n if type(actual) == 'string' then\n if type(item) ~= 'string' then\n raise(string_format('toContain finds strings in strings, not %s', type(item)))\n end\n found = string_find(actual, item, 1, true) ~= nil\n elseif type(actual) == 'table' then\n found = holds(actual, item, same)\n else\n raise(string_format('toContain finds values in strings and tables, not in %s', type(actual)))\n end\n return found,\n string_format('expected %s to contain %s', show(actual), show(item)),\n string_format('expected %s not to contain %s', show(actual), show(item))\nend\n\nfunction matchers.toContainEqual(actual, item)\n if type(actual) ~= 'table' then\n raise(string_format('toContainEqual finds values in tables, not in %s', type(actual)))\n end\n return holds(actual, item, equal),\n string_format('expected %s to contain a value equal to %s', show(actual), show(item)),\n string_format('expected %s not to contain a value equal to %s', show(actual), show(item))\nend\n\nfunction matchers.toHaveLength(actual, length)\n if type(actual) ~= 'string' and type(actual) ~= 'table' then\n raise(string_format('toHaveLength measures strings and tables, not %s', type(actual)))\n end\n local actualLength = #actual\n return actualLength == length,\n string_format('expected %s to have length %s, not %s', show(actual), show(length), show(actualLength)),\n string_format('expected %s not to have length %s', show(actual), show(length))\nend\n\nfunction matchers.toMatch(actual, pattern)\n if type(actual) ~= 'string' or type(pattern) ~= 'string' then\n raise(string_format('toMatch matches strings with patterns, not %s with %s', type(actual), type(pattern)))\n end\n local ok, found = pcall(string_find, actual, pattern)\n if not ok then\n raise(string_format('toMatch takes Lua patterns: %s', found))\n end\n return found ~= nil,\n string_format('expected %s to match %s', show(actual), show(pattern)),\n string_format('expected %s not to match %s', show(actual), show(pattern))\nend\n\nfunction matchers.toThrow(actual, expected)\n if type(actual) ~= 'function' then\n raise(string_format('toThrow calls functions, not %s', type(actual)))\n end\n if expected ~= nil and type(expected) ~= 'string' then\n raise(string_format('toThrow finds strings in what functions throw, not %s', type(expected)))\n end\n local ok, err = pcall(actual)\n local with = expected == nil and '' or string_format(' an error with %s', show(expected))\n if ok then\n return false, string_format('expected the function to throw%s', with), ''\n end\n local message = messageOf(err)\n return expected == nil or string_find(message, expected, 1, true) ~= nil,\n string_format('expected the function to throw%s, but it threw %s', with, show(message)),\n string_format('expected the function not to throw%s, but it threw %s', with, show(message))\nend\n\n-- Runs a matcher on the value that expect took, and fails the test if the\n-- value does not match, or if it does but `negated` says it should not.\nlocal function check(expectation, name, matcher, actual, negated, ...)\n if rawequal((...), expectation) then\n raise(string_format('call %s with a dot, as in expect(value).%s(...), not with a colon', name, name))\n end\n local matches, message, negatedMessage = matcher(actual, ...)\n if matches == negated then\n error(setmetatable({ message = negated and negatedMessage or message }, Failure))\n end\nend\n\n-- Takes a value to check with the matchers that it gives, or that its\n-- field never gives, which fail when the value matches.\nfunction expect(actual)\n local expectation, negation = {}, {}\n for name, matcher in next, matchers do\n expectation[name] = function(...)\n check(expectation, name, matcher, actual, false, ...)\n end\n negation[name] = function(...)\n check(negation, name, matcher, actual, true, ...)\n end\n end\n expectation.never = negation\n return expectation\nend\n\n--[[ Players ]]\n\nfiverunner = {}\n\n-- The ids of the players whose clients started the resources of the server.\nlocal ready = {}\n-- What the clients of players gave back of the code they ran, by request.\nlocal results = {}\nlocal requests = 0\n\nRegisterNetEvent('fiverunner:ready', function()\n ready[source] = true\nend)\n\nRegisterNetEvent('fiverunner:result', function(request, ok, values)\n results[request] = { ok = ok, values = values }\nend)\n\n-- What players are, by the types of what the identities of players have.\nlocal IDENTITY = { name = 'string', guid = 'string', endpoint = 'string', identifiers = 'table', tokens = 'table', card = 'table' }\n\n-- A player that connected, by its id on the server and its name.\nlocal Player = {}\nPlayer.__index = Player\n\n-- Connects a player, as its identity says: its name, its guid, the\n-- endpoint it connects from, its identifiers and tokens, which are lists\n-- of strings, and what it answers the cards of deferrals with. Returns the\n-- player once its client started the resources of the server, or nil and\n-- why the server refused it.\nfunction fiverunner.connect(identity)\n if identity ~= nil then\n checkArgument('fiverunner.connect', 1, identity, 'table')\n for key, value in next, identity do\n local expected = IDENTITY[key]\n if expected == nil then\n raise(string_format('players have a name, a guid, an endpoint, identifiers, tokens and a card, not %s', show(key)))\n end\n if type(value) ~= expected then\n raise(string_format('the %s of a player is a %s, not a %s', key, expected, type(value)))\n end\n if expected == 'table' and key ~= 'card' then\n for _, item in next, value do\n if type(item) ~= 'string' then\n raise(string_format('the %s of a player are strings, not %s', key, type(item)))\n end\n end\n end\n end\n end\n local answer = identity ~= nil and identity.card ~= nil and json.encode(identity.card) or nil\n local connection = ask('connect', { identity = identity, answer = answer })\n while true do\n local state = ask('connection', connection)\n if state.state == 'rejected' then\n return nil, state.reason\n elseif state.state == 'dropped' then\n return nil, 'the server dropped the player'\n elseif state.state == 'joined' and ready[state.id] then\n return setmetatable({ id = state.id, name = state.name }, Player)\n end\n Wait(0)\n end\nend\n\n-- Runs `code` in the client of the player, as a chunk of its own that\n-- takes `...`, in a thread of its own, and returns what it returns once\n-- the client gave it back. Raises what the code raised.\nfunction Player:run(code, ...)\n checkArgument('run', 1, code, 'string')\n requests = requests + 1\n local request = requests\n TriggerClientEvent('fiverunner:run', self.id, request, code, table_pack(...))\n while results[request] == nil do\n if not DoesPlayerExist(self.id) then\n raise(string_format('player %d left before its client ran the code', self.id))\n end\n Wait(0)\n end\n local result = results[request]\n results[request] = nil\n if not result.ok then\n raise(string_format('the client of player %d raised: %s', self.id, result.values))\n end\n return table_unpack(result.values, 1, result.values.n)\nend\n\n-- Triggers `event` on the server from the client of the player, with\n-- `...`, and returns once the server got it.\nfunction Player:emit(event, ...)\n checkArgument('emit', 1, event, 'string')\n self:run('TriggerServerEvent(...)', event, ...)\nend\n\n-- The arguments of each `event` that the client of the player got from the\n-- network, in the order they came.\nfunction Player:received(event)\n checkArgument('received', 1, event, 'string')\n return ask('received', { id = self.id, event = event })\nend\n\n-- What the client of the player printed on its console, line by line, from\n-- line `from` on, when given.\nfunction Player:output(from)\n if from ~= nil then\n checkArgument('output', 1, from, 'number')\n end\n return ask('output', { id = self.id, from = from })\nend\n\n-- Disconnects the client of the player, as players quit, for `reason`,\n-- and returns once the server dropped the player.\nfunction Player:disconnect(reason)\n if reason ~= nil then\n checkArgument('disconnect', 1, reason, 'string')\n end\n ask('disconnect', { id = self.id, reason = reason })\n while DoesPlayerExist(self.id) do\n Wait(0)\n end\nend\n\n-- Has `fn` run in place of the native that the Lua global `name` stands\n-- for, on the server or on the client of a player, for the rest of the\n-- test, with the arguments of the native, for the first of its results to\n-- be what the native returns.\nlocal function mock(id, name, fn)\n checkArgument('mock', 1, name, 'string')\n checkArgument('mock', 2, fn, 'function')\n if not ask('mock', { id = id, name = name, fn = fn }) then\n raise(string_format('there is no native %s to mock', show(name)))\n end\nend\n\nfunction fiverunner.mock(name, fn)\n mock(nil, name, fn)\nend\n\nfunction Player:mock(name, fn)\n mock(self.id, name, fn)\nend\n\n-- What the scripts of the client of the player sent the frame of the NUI\n-- of `resource`, as SendNUIMessage gives it, in the order they sent it.\nfunction Player:nuiMessages(resource)\n checkArgument('nuiMessages', 1, resource, 'string')\n local messages = {}\n for i, text in ipairs(ask('nuiMessages', { id = self.id, resource = resource })) do\n messages[i] = json.decode(text)\n end\n return messages\nend\n\n-- Calls the NUI callback `name` of `resource` on the client of the\n-- player, as the page of the resource does, with `data` as JSON, and\n-- returns what the callback answered once it did, as JSON reads it, or as\n-- it is when it is no JSON, and the status of the answer.\nfunction Player:nuiCallback(resource, name, data)\n checkArgument('nuiCallback', 1, resource, 'string')\n checkArgument('nuiCallback', 2, name, 'string')\n local request = ask('nuiCallback', { id = self.id, resource = resource, name = name, data = json.encode(data or {}) })\n while true do\n local answer = ask('nuiAnswer', request)\n if answer.status ~= nil then\n local ok, value = pcall(json.decode, answer.body)\n if ok then\n return value, answer.status\n end\n return answer.body, answer.status\n end\n Wait(0)\n end\nend\n\n-- Whether NUI has the focus on the client of the player, and whether it\n-- shows the cursor, as SetNuiFocus left them.\nfunction Player:nuiFocus()\n local focus = ask('nuiFocus', { id = self.id })\n return focus.focused, focus.cursor\nend\n\n-- Has the key `key` of the keyboard of the client of the player, by the\n-- name of FiveM's keyboard mapper, such as E or LMENU, go down before the\n-- next frame: it presses the controls of the game that it presses, and\n-- runs the commands that resources bound to it with RegisterKeyMapping.\nfunction Player:keyDown(key)\n checkArgument('keyDown', 1, key, 'string')\n ask('key', { id = self.id, key = key, down = true })\nend\n\n-- Has the key `key` go up before the next frame, which runs -command for\n-- the +command that resources bound to it.\nfunction Player:keyUp(key)\n checkArgument('keyUp', 1, key, 'string')\n ask('key', { id = self.id, key = key, down = false })\nend\n\n-- Presses the key `key`: it goes down, and up a frame later.\nfunction Player:press(key)\n self:keyDown(key)\n Wait(0)\n self:keyUp(key)\nend\n\n-- What the server printed on its console, line by line, from line `from` on,\n-- when given.\nfunction fiverunner.output(from)\n if from ~= nil then\n checkArgument('fiverunner.output', 1, from, 'number')\n end\n return ask('output', { from = from })\nend\n\n-- The events that the resources of the server handle, as { name, resources },\n-- in the order they first registered for them, those of tests left out.\nfunction fiverunner.events()\n return ask('events')\nend\n\n-- Calls `check` at each tick until it returns something truthy, and\n-- returns that, or fails the test once `timeout` milliseconds went by.\nfunction fiverunner.waitFor(check, timeout)\n checkArgument('fiverunner.waitFor', 1, check, 'function')\n if timeout ~= nil then\n checkArgument('fiverunner.waitFor', 2, timeout, 'number')\n end\n local start = GetGameTimer()\n while true do\n local value = check()\n if value then\n return value\n end\n if timeout ~= nil and GetGameTimer() - start >= timeout then\n error(setmetatable({ message = string_format('waitFor gave up after %s ms', show(timeout)) }, Failure))\n end\n Wait(0)\n end\nend\n\n--[[ Running tests ]]\n\nlocal target = ask('target')\nlocal chunkName = string_format('@@%s/%s', resource, target.file)\n\n-- Reports no more than this many lines that failures came through.\nlocal REPORTED_FRAMES = 20\n\n-- What failed, from what was raised: the message, with the lines of the\n-- test file that the stack was at, innermost first.\nlocal function failureOf(err)\n local frames = {}\n local level = 2\n while #frames < REPORTED_FRAMES do\n local info = debug_getinfo(level, 'Sln')\n if info == nil then\n break\n end\n if info.source == chunkName then\n frames[#frames + 1] = { line = info.currentline, name = info.name }\n end\n level = level + 1\n end\n return { message = messageOf(err), frames = frames }\nend\n\n-- Calls `fn`, and returns what failed, if anything did. Lua calls no\n-- handler for errors of memory, and gives the error of the handler if\n-- the handler fails.\nlocal function attempt(fn)\n local failure\n local ok, err = xpcall(fn, function(raised)\n failure = failureOf(raised)\n end)\n if ok then\n return nil\n end\n return failure or { message = messageOf(err), frames = {} }\nend\n\n-- Runs a test with the hooks of the describes it is in: the beforeEach\n-- hooks from the outermost describe in, the test if they did not fail, and\n-- the afterEach hooks from the innermost describe out, in each the last\n-- first. Returns what failed.\nlocal function run(test)\n local failures = {}\n local function note(failure)\n failures[#failures + 1] = failure\n return failure == nil\n end\n local suites = {}\n local at = test.suite\n while at ~= nil do\n table_insert(suites, 1, at)\n at = at.parent\n end\n local ready = true\n for _, inner in ipairs(suites) do\n for _, fn in ipairs(inner.beforeEach) do\n ready = ready and note(attempt(fn))\n end\n end\n if ready then\n note(attempt(test.fn))\n end\n for index = #suites, 1, -1 do\n local hooks = suites[index].afterEach\n for hookIndex = #hooks, 1, -1 do\n note(attempt(hooks[hookIndex]))\n end\n end\n return failures\nend\n\nlocal loadFailure\nlocal chunk, syntaxError = load(LoadResourceFile(resource, target.file), chunkName, 't')\nif chunk == nil then\n loadFailure = { message = syntaxError, frames = {} }\nelse\n loadFailure = attempt(chunk)\nend\nloading = false\n\nif target.test == nil then\n local collected = {}\n for index, entry in ipairs(tests) do\n collected[index] = { names = namesOf(entry), mode = modeOf(entry), timeout = entry.timeout }\n end\n ask('report', { tests = collected, failure = loadFailure })\nelse\n local entry = tests[target.test]\n if loadFailure ~= nil or entry == nil then\n local failure = loadFailure or { message = 'the test file has fewer tests than it had', frames = {} }\n ask('report', { failures = { failure } })\n else\n -- The scripts of the project start their threads before the test runs.\n CreateThread(function()\n local failures = run(entry)\n ask('report', { names = namesOf(entry), failures = failures })\n end)\n end\nend\n";
102607
102168
  //#endregion
102608
102169
  //#region src/runner/output.ts
102609
102170
  /**
@@ -102798,7 +102359,7 @@ var World = class World {
102798
102359
  * `test`th of them.
102799
102360
  */
102800
102361
  static async create(options, file, test, parse) {
102801
- const output = new Output();
102362
+ const output = new Output(options.onLine);
102802
102363
  const pending = [];
102803
102364
  let world;
102804
102365
  const call = defineNative({
@@ -102885,11 +102446,11 @@ var World = class World {
102885
102446
  const { name, id } = fields(args);
102886
102447
  const reference = (object?.type === "map" ? object.entries.find(([key]) => plain(key) === "fn") : void 0)?.[1];
102887
102448
  if (reference?.type !== "ext") throw new Error("Mocks need a function");
102888
- const ref = decoder.decode(reference.extension.data);
102449
+ const ref = decoder$2.decode(reference.extension.data);
102889
102450
  const { server } = this.engine;
102890
- const native = (id === void 0 ? server : this.#connectionOf(count(id)).client).natives.mock(text(name), (_, ...values) => {
102451
+ const native = (id === void 0 ? server : this.#connectionOf(count(id)).client).natives.mock(text$1(name), (_, ...values) => {
102891
102452
  const results = server.invokeFunctionReference(ref, pack(values));
102892
- if (results == null) throw new Error(`the mock of ${text(name)} failed`);
102453
+ if (results == null) throw new Error(`the mock of ${text$1(name)} failed`);
102893
102454
  return resultOf(native.sets ?? native.result, unpackObject(results));
102894
102455
  });
102895
102456
  return pack(native !== void 0);
@@ -102905,32 +102466,38 @@ var World = class World {
102905
102466
  const { player } = this.#connectionOf(count(id));
102906
102467
  return packObject({
102907
102468
  type: "array",
102908
- items: this.#received.filter((received) => received.player === player && received.name === text(event)).map((received) => unpackObject(received.payload))
102469
+ items: this.#received.filter((received) => received.player === player && received.name === text$1(event)).map((received) => unpackObject(received.payload))
102909
102470
  });
102910
102471
  }
102472
+ case "events": return pack(this.engine.server.events.registered().map(([name, resources]) => ({
102473
+ name,
102474
+ resources: resources.filter((resource) => resource !== TEST_RESOURCE)
102475
+ })).filter(({ resources }) => resources.length > 0));
102911
102476
  case "output": {
102912
- const { id } = fields(args);
102477
+ const { id, from } = fields(args);
102913
102478
  const process = id === void 0 ? "server" : this.#connectionOf(count(id)).process;
102914
- return pack(this.output.lines.filter((line) => line.process === process && line.kind === "console").map(({ channel, text }) => `[${channel}] ${text.replace(/\^\d/g, "")}`));
102479
+ const lines = this.output.lines.filter((line) => line.process === process && line.kind === "console");
102480
+ const first = from === void 0 ? 0 : Math.max(0, Math.trunc(Number(from)) - 1);
102481
+ return pack(lines.slice(first).map(({ channel, text }) => `[${channel}] ${text.replace(/\^\d/g, "")}`));
102915
102482
  }
102916
102483
  case "nuiMessages": {
102917
102484
  const { id, resource } = fields(args);
102918
102485
  const { messages } = this.#connectionOf(count(id));
102919
- return pack(messages.filter((message) => message.resource === text(resource)).map(({ json }) => json));
102486
+ return pack(messages.filter((message) => message.resource === text$1(resource)).map(({ json }) => json));
102920
102487
  }
102921
102488
  case "nuiCallback": {
102922
102489
  const { id, resource, name, data } = fields(args);
102923
102490
  const nui = this.#connectionOf(count(id)).client.nui;
102924
102491
  const answer = {};
102925
- nui.request(text(resource), {
102492
+ nui.request(text$1(resource), {
102926
102493
  method: "POST",
102927
- path: `/${text(name)}`,
102494
+ path: `/${text$1(name)}`,
102928
102495
  query: "",
102929
- headers: [["Content-Type", "application/json; charset=UTF-8"], ["Origin", `https://cfx-nui-${text(resource)}`]],
102930
- body: encoder.encode(text(data))
102496
+ headers: [["Content-Type", "application/json; charset=UTF-8"], ["Origin", `https://cfx-nui-${text$1(resource)}`]],
102497
+ body: encoder$1.encode(text$1(data))
102931
102498
  }).then((response) => {
102932
102499
  answer.status = response.status;
102933
- answer.body = decoder.decode(response.body);
102500
+ answer.body = decoder$2.decode(response.body);
102934
102501
  });
102935
102502
  this.#answers.push(answer);
102936
102503
  return pack(this.#answers.length);
@@ -102956,7 +102523,7 @@ var World = class World {
102956
102523
  const client = this.#connectionOf(count(id)).client;
102957
102524
  this.#actions.push(() => this.#survive(() => {
102958
102525
  const { input } = client.world;
102959
- const commands = down === true ? input.keyDown(text(key)) : input.keyUp(text(key));
102526
+ const commands = down === true ? input.keyDown(text$1(key)) : input.keyUp(text$1(key));
102960
102527
  for (const command of commands) client.console.commands.invoke(command, "");
102961
102528
  }));
102962
102529
  return pack(null);
@@ -102964,7 +102531,7 @@ var World = class World {
102964
102531
  case "disconnect": {
102965
102532
  const { id, reason } = fields(args);
102966
102533
  const { player } = this.#connectionOf(count(id));
102967
- const why = reason === void 0 ? void 0 : text(reason);
102534
+ const why = reason === void 0 ? void 0 : text$1(reason);
102968
102535
  this.#actions.push(() => this.#survive(() => player.quit(why)));
102969
102536
  return pack(null);
102970
102537
  }
@@ -102984,7 +102551,7 @@ var World = class World {
102984
102551
  player: void 0,
102985
102552
  client: void 0,
102986
102553
  messages: [],
102987
- ...answer === void 0 ? {} : { answer: text(answer) }
102554
+ ...answer === void 0 ? {} : { answer: text$1(answer) }
102988
102555
  };
102989
102556
  this.#connections.push(connection);
102990
102557
  this.#actions.push(async () => {
@@ -103051,7 +102618,13 @@ var World = class World {
103051
102618
  /** What the processes of a server printed and reported, line by line. */
103052
102619
  var Output = class {
103053
102620
  #lines = [];
103054
- #writer = new LineOutput((line) => this.#lines.push(line));
102621
+ #writer;
102622
+ constructor(onLine) {
102623
+ this.#writer = new LineOutput((line) => {
102624
+ this.#lines.push(line);
102625
+ onLine?.(line);
102626
+ });
102627
+ }
103055
102628
  /** Where the process `process` writes. */
103056
102629
  of(process) {
103057
102630
  return this.#writer.of(process);
@@ -103061,8 +102634,8 @@ var Output = class {
103061
102634
  return [...this.#lines, ...this.#writer.unended];
103062
102635
  }
103063
102636
  };
103064
- var decoder = new TextDecoder();
103065
- var encoder = new TextEncoder();
102637
+ var decoder$2 = new TextDecoder();
102638
+ var encoder$1 = new TextEncoder();
103066
102639
  /** A msgpack value as JavaScript has it, with maps as objects by their keys as strings. */
103067
102640
  function plain(object) {
103068
102641
  switch (object.type) {
@@ -103074,7 +102647,7 @@ function plain(object) {
103074
102647
  case "positive":
103075
102648
  case "negative": return Number(object.value);
103076
102649
  case "str":
103077
- case "bin": return decoder.decode(object.bytes);
102650
+ case "bin": return decoder$2.decode(object.bytes);
103078
102651
  case "array": return object.items.map(plain);
103079
102652
  case "map": return Object.fromEntries(object.entries.map(([key, value]) => [plain(key), plain(value)]));
103080
102653
  }
@@ -103116,7 +102689,7 @@ function loadedOf(value) {
103116
102689
  function ranOf(value) {
103117
102690
  const ran = record(value);
103118
102691
  return {
103119
- names: ran.names === void 0 ? void 0 : list(ran.names).map(text),
102692
+ names: ran.names === void 0 ? void 0 : list(ran.names).map(text$1),
103120
102693
  failures: list(ran.failures).map(failureOf)
103121
102694
  };
103122
102695
  }
@@ -103126,7 +102699,7 @@ function collectedOf(value) {
103126
102699
  if (mode !== "run" && mode !== "skip" && mode !== "only") throw new Error(`Unknown mode ${String(mode)}`);
103127
102700
  if (timeout !== void 0 && typeof timeout !== "number") throw new Error("Timeouts are numbers");
103128
102701
  return {
103129
- names: list(test.names).map(text),
102702
+ names: list(test.names).map(text$1),
103130
102703
  mode,
103131
102704
  timeout
103132
102705
  };
@@ -103138,11 +102711,11 @@ function failureOf(value) {
103138
102711
  if (typeof frame.line !== "number") throw new Error("Frames have lines");
103139
102712
  return frame.name === void 0 ? { line: frame.line } : {
103140
102713
  line: frame.line,
103141
- name: text(frame.name)
102714
+ name: text$1(frame.name)
103142
102715
  };
103143
102716
  });
103144
102717
  return {
103145
- message: text(failure.message),
102718
+ message: text$1(failure.message),
103146
102719
  frames
103147
102720
  };
103148
102721
  }
@@ -103168,18 +102741,594 @@ function count(value) {
103168
102741
  function identityOf(value) {
103169
102742
  const { name, guid, endpoint, identifiers, tokens } = fields(value);
103170
102743
  return {
103171
- ...name === void 0 ? {} : { name: text(name) },
103172
- ...guid === void 0 ? {} : { guid: text(guid) },
103173
- ...endpoint === void 0 ? {} : { endpoint: text(endpoint) },
103174
- identifiers: identifiers === void 0 ? [licenseOf(name === void 0 ? void 0 : text(name))] : list(identifiers).map(text),
103175
- ...tokens === void 0 ? {} : { tokens: list(tokens).map(text) }
102744
+ ...name === void 0 ? {} : { name: text$1(name) },
102745
+ ...guid === void 0 ? {} : { guid: text$1(guid) },
102746
+ ...endpoint === void 0 ? {} : { endpoint: text$1(endpoint) },
102747
+ identifiers: identifiers === void 0 ? [licenseOf(name === void 0 ? void 0 : text$1(name))] : list(identifiers).map(text$1),
102748
+ ...tokens === void 0 ? {} : { tokens: list(tokens).map(text$1) }
103176
102749
  };
103177
102750
  }
103178
- function text(value) {
102751
+ function text$1(value) {
103179
102752
  if (typeof value !== "string") throw new Error("Expected a string");
103180
102753
  return value;
103181
102754
  }
103182
102755
  //#endregion
102756
+ //#region src/cli/audit.lua?raw
102757
+ var audit_default = "-- FiveRunner's audit, which `fiverunner audit` runs as a test of the project:\n-- it triggers each event of the server that clients may trigger, from a\n-- player, with the arguments that cheaters give, and tells what each changed\n-- of what the player has, of the database and of the world, as lines of JSON.\n\nlocal MARK = 'fiverunner:audit '\n\n--- Tells `fiverunner audit` of something, as a line of JSON.\nlocal function tell(record)\n print(MARK .. json.encode(record))\nend\n\nlocal function started(name)\n return GetResourceState(name) == 'started'\nend\n\n-- The framework that logs players in, and the inventory that holds their items.\nlocal framework = started('qbx_core') and 'qbx' or started('qb-core') and 'qb' or started('es_extended') and 'esx' or nil\nlocal oxInventory = started('ox_inventory')\n\nlocal CHARINFO = { firstname = 'Audit', lastname = 'Player', birthdate = '1990-01-01', gender = 0, nationality = 'Romania' }\n\nlocal core\n\n--- The shared object of QBCore or ESX, which exports copy whole, so once.\nlocal function coreOf()\n if core == nil then\n if framework == 'qb' then core = exports['qb-core']:GetCoreObject() end\n if framework == 'esx' then core = exports.es_extended:getSharedObject() end\n end\n return core\nend\n\n--- The object of the framework for a player, once it logged in.\nlocal function frameworkPlayer(id)\n local ok, found = pcall(function()\n if framework == 'qbx' then return exports.qbx_core:GetPlayer(id) end\n if framework == 'qb' then return coreOf().Functions.GetPlayer(id) end\n if framework == 'esx' then return coreOf().GetPlayerFromId(id) end\n end)\n return ok and found or nil\nend\n\n--- Whether the client of `player` sent the page of `resource` a message of `action`.\nlocal function sent(player, resource, action)\n for _, message in ipairs(player:nuiMessages(resource)) do\n if message.action == action then return true end\n end\n return false\nend\n\nlocal joined = 0\n\n--- Connects a player and logs it in with a new character, as the framework of the server has players do.\nlocal function join()\n joined = joined + 1\n local player, why = fiverunner.connect({ name = 'audit' .. joined })\n if not player then return nil, why end\n pcall(function()\n if framework == 'qbx' then\n exports.qbx_core:Login(player.id, nil, { charinfo = CHARINFO })\n elseif framework == 'qb' and started('qb-multicharacter') then\n fiverunner.waitFor(function() return sent(player, 'qb-multicharacter', 'ui') end, 10000)\n player:nuiCallback('qb-multicharacter', 'createNewCharacter', {\n firstname = 'Audit', lastname = 'Player', nationality = 'Romania', birthdate = '1990-01-01', gender = 'Male', cid = 1,\n })\n elseif framework == 'qb' then\n coreOf().Player.Login(player.id, false, { charinfo = CHARINFO })\n end\n end)\n if framework then\n pcall(fiverunner.waitFor, function() return frameworkPlayer(player.id) end, 10000)\n end\n return player\nend\n\n--- Whether `player` is still on the server, and still logged in to its framework.\nlocal function present(player)\n if GetPlayerName(player.id) == nil then return false end\n return framework == nil or frameworkPlayer(player.id) ~= nil\nend\n\n--- What `player` has: money by account, items by name, job, gang, group, and whether it may run every command.\nlocal function holdings(player)\n local held = { money = {}, items = {}, admin = IsPlayerAceAllowed(player.id, 'command') }\n local found = frameworkPlayer(player.id)\n pcall(function()\n if found and framework == 'esx' then\n for account, money in pairs(found.getAccounts(true)) do held.money[account] = money end\n if not oxInventory then\n for name, count in pairs(found.getInventory(true)) do held.items[name] = count end\n end\n local job = found.getJob()\n held.job = ('%s %s'):format(job.name, job.grade)\n held.group = found.getGroup()\n elseif found then\n local data = found.PlayerData\n for account, money in pairs(data.money or {}) do held.money[account] = money end\n if not oxInventory then\n for _, item in pairs(data.items or {}) do\n held.items[item.name] = (held.items[item.name] or 0) + (item.amount or item.count or 0)\n end\n end\n if data.job then held.job = ('%s %s'):format(data.job.name, data.job.grade and data.job.grade.level) end\n if data.gang then held.gang = ('%s %s'):format(data.gang.name, data.gang.grade and data.gang.grade.level) end\n end\n end)\n if oxInventory then\n pcall(function()\n for _, item in pairs(exports.ox_inventory:GetInventoryItems(player.id) or {}) do\n held.items[item.name] = (held.items[item.name] or 0) + item.count\n end\n end)\n end\n return held\nend\n\nlocal function byText(a, b)\n return a.text < b.text\nend\n\n--- What `player` gained from `before` to `after`, as { kind, text }, with\n--- kind money, item or field, and the money that it lost, as words.\nlocal function diff(before, after)\n local gained, lost = {}, {}\n for account, money in pairs(after.money) do\n local was = before.money[account] or 0\n if type(money) == 'number' and type(was) == 'number' then\n if money > was then\n gained[#gained + 1] = { kind = 'money', text = ('%s +%s'):format(account, money - was) }\n elseif money < was then\n lost[#lost + 1] = ('%s -%s'):format(account, was - money)\n end\n end\n end\n for name, count in pairs(after.items) do\n local was = before.items[name] or 0\n if count > was then gained[#gained + 1] = { kind = 'item', text = ('item %s +%s'):format(name, count - was) } end\n end\n for _, field in ipairs({ 'job', 'gang', 'group' }) do\n if after[field] ~= before[field] and after[field] ~= nil then\n gained[#gained + 1] = { kind = 'field', text = ('%s %s'):format(field, after[field]) }\n end\n end\n if after.admin and not before.admin then gained[#gained + 1] = { kind = 'field', text = 'every command' } end\n table.sort(gained, byText)\n table.sort(lost)\n return gained, lost\nend\n\n--- Whether `changes` has any of `kind`.\nlocal function has(changes, kind)\n for _, change in ipairs(changes) do\n if change.kind == kind then return true end\n end\n return false\nend\n\n--- What `player` holds once it stays the same for 2 seconds, within 10, as\n--- frameworks give new characters their starter items a moment after they\n--- log in: QBCore once its other resources had a second to load the player.\nlocal function settled(player)\n local before, stable = holdings(player), 0\n for _ = 1, 20 do\n Wait(500)\n local now = holdings(player)\n local gained, lost = diff(before, now)\n stable = (#gained == 0 and #lost == 0) and stable + 1 or 0\n if stable == 4 then return now end\n before = now\n end\n return before\nend\n\n--- The rows of each table of the database, as JSON, by the names of the tables.\nlocal function tables()\n local rows = {}\n if not started('oxmysql') then return rows end\n pcall(function()\n for _, row in ipairs(exports.oxmysql:query_async('SHOW TABLES') or {}) do\n local _, name = next(row)\n rows[name] = json.encode(exports.oxmysql:query_async(('SELECT * FROM `%s`'):format(name)))\n end\n end)\n return rows\nend\n\n--- The tables whose rows differ from `before` to `after`.\nlocal function changed(before, after)\n local found = {}\n for name, rows in pairs(after) do\n if before[name] ~= rows then found[#found + 1] = name end\n end\n table.sort(found)\n return found\nend\n\nlocal function entities()\n return #GetAllVehicles() + #GetAllPeds() + #GetAllObjects()\nend\n\n--- An item that the server knows of, for arguments that name items.\nlocal function anItem()\n local name\n pcall(function()\n if oxInventory then\n name = next(exports.ox_inventory:Items())\n elseif framework == 'qb' then\n name = next(coreOf().Shared.Items)\n elseif framework == 'esx' then\n name = next(coreOf().Items)\n end\n end)\n return name or 'water'\nend\n\nit('audits the events that clients may trigger', function()\n while GetGameTimer() < 3000 do Wait(100) end\n local events = fiverunner.events()\n\n -- Which events clients may trigger. Each resource that handles one but not\n -- for net refuses it from clients, saying so, so a scout triggers them all at\n -- once, and those that every resource of theirs refused are closed. The scout\n -- leaves, so that what the events did to it is nothing of the player's.\n local scout, why = fiverunner.connect({ name = 'scout' })\n if not scout then\n tell({ type = 'refused', reason = why })\n return\n end\n local names = {}\n for _, event in ipairs(events) do\n if event.name ~= '*' then names[#names + 1] = event.name end\n end\n local from = #fiverunner.output() + 1\n scout:run('for _, name in ipairs(...) do TriggerServerEvent(name) end', names)\n Wait(500)\n local refusals = {}\n for _, line in ipairs(fiverunner.output(from)) do\n local name = line:match('[Ee]vent (.-) was not safe for net')\n if name then refusals[name] = (refusals[name] or 0) + 1 end\n end\n if GetPlayerName(scout.id) ~= nil then scout:disconnect() end\n\n local player\n player, why = join()\n if not player then\n tell({ type = 'refused', reason = why })\n return\n end\n local item = anItem()\n local payloads = {\n {},\n { 1000000 },\n { -1000000 },\n { 'money', 1000000 },\n { 'bank', 1000000 },\n { item, 100 },\n { 'police', 4 },\n { player.id, 1000000 },\n { { amount = 1000000, count = 100, item = item, money = 1000000, price = -1000000 } },\n }\n local openEvents = 0\n for _, event in ipairs(events) do\n if event.name ~= '*' and (refusals[event.name] or 0) < #event.resources then openEvents = openEvents + 1 end\n end\n tell({ type = 'start', framework = framework, events = #events, open = openEvents, loggedIn = frameworkPlayer(player.id) ~= nil })\n local RANK = { low = 1, medium = 2, high = 3 }\n\n local held, rows, count = settled(player), tables(), entities()\n local seen = #fiverunner.output()\n for _, event in ipairs(events) do\n local name, resources = event.name, event.resources\n local open = name ~= '*' and (refusals[name] or 0) < #resources\n -- What the arguments that did the most did, of all that the event got.\n local worst\n for _, payload in ipairs(open and payloads or {}) do\n player:emit(name, table.unpack(payload))\n Wait(100)\n local errors = {}\n -- Only what the server printed since it last read, which may be long.\n local output = fiverunner.output(seen + 1)\n for _, line in ipairs(output) do\n if line:find('SCRIPT ERROR', 1, true) and not line:find(MARK, 1, true) then\n errors[#errors + 1] = line\n end\n end\n seen = seen + #output\n local effects, severity = {}, nil\n local stayed = present(player)\n if stayed then\n local now = holdings(player)\n local gained, lost = diff(held, now)\n held = now\n -- Money that comes at once may be pay that comes on its own, such as\n -- paychecks, so it counts only when the same arguments bring more.\n if has(gained, 'money') then\n player:emit(name, table.unpack(payload))\n Wait(100)\n stayed = present(player)\n local again = stayed and holdings(player) or now\n if not has((diff(now, again)), 'money') then\n for i = #gained, 1, -1 do\n if gained[i].kind == 'money' then table.remove(gained, i) end\n end\n end\n held = again\n end\n for _, gain in ipairs(gained) do effects[#effects + 1] = gain.text end\n if #gained > 0 then\n -- What the player paid for, such as an item of a shop, is a trade.\n severity = #lost > 0 and 'medium' or 'high'\n for _, loss in ipairs(lost) do effects[#effects + 1] = loss end\n end\n end\n local nowCount = entities()\n if nowCount > count then\n effects[#effects + 1] = ('%d entities'):format(nowCount - count)\n severity = severity or 'medium'\n end\n count = nowCount\n if #errors > 0 then\n effects[#effects + 1] = 'error: ' .. errors[1]\n severity = severity or 'low'\n end\n if severity and (worst == nil or RANK[severity] > RANK[worst.severity]) then\n worst = { severity = severity, args = payload, effects = effects }\n end\n if not stayed then\n tell({ type = 'dropped', event = name, resources = resources, args = payload })\n player = join()\n if not player then return end\n payloads[8][1] = player.id\n -- What joining did is no effect of the event, whose other arguments\n -- would only drop the new player, such as by a ban, as anti-cheats do.\n held, rows, count = settled(player), tables(), entities()\n seen = seen + #fiverunner.output(seen + 1)\n break\n end\n end\n -- The rows of the database, once all the arguments went, as reading them\n -- takes far longer than the rest: whichever changed them, the event writes\n -- what clients send.\n if open then\n local nowRows = tables()\n local tablesChanged = changed(rows, nowRows)\n rows = nowRows\n if #tablesChanged > 0 then\n local effect = 'database: ' .. table.concat(tablesChanged, ', ')\n if worst and RANK[worst.severity] >= RANK.medium then\n worst.effects[#worst.effects + 1] = effect\n else\n worst = { severity = 'medium', effects = { effect, table.unpack(worst and worst.effects or {}) } }\n end\n end\n end\n if worst then\n tell({ type = 'finding', event = name, resources = resources, args = worst.args, severity = worst.severity, effects = worst.effects })\n end\n tell({ type = 'event', event = name, resources = resources, open = open })\n -- What the event left behind, such as copies of the objects of frameworks,\n -- is garbage that generational collection lets grow on servers of many resources.\n collectgarbage()\n end\n tell({ type = 'end' })\nend, 3600000)\n";
102758
+ //#endregion
102759
+ //#region src/cli/audit.ts
102760
+ var USAGE$4 = `Usage: fiverunner audit
102761
+
102762
+ Audits the project in this directory as cheaters would: a player triggers
102763
+ each event of the server that clients may trigger, with the arguments that
102764
+ cheaters give, and the audit tells what each changed. A player who gains
102765
+ money, items, a job or permissions is a finding of high severity, a change
102766
+ to the database or new entities one of medium, and an error one of low.
102767
+ The player logs in with a character when Qbox, QBCore or ESX runs.
102768
+
102769
+ It exits with 1 when it found anything of high severity, so CI can fail on it.
102770
+
102771
+ Options:
102772
+ --json Write what it found as JSON
102773
+ -h, --help Show this help
102774
+ `;
102775
+ /** What starts the lines of the script of the audit. */
102776
+ var MARK = "fiverunner:audit ";
102777
+ /** Where the script of the audit runs from, in the resource of tests. */
102778
+ var PATH = ".fiverunner/audit.test.lua";
102779
+ var HEADINGS = {
102780
+ high: "High: what any client gains",
102781
+ medium: "Medium: what any client changes",
102782
+ low: "Low: errors on what clients send"
102783
+ };
102784
+ /**
102785
+ * Runs the audit of the project in the directory that the tool runs in, and
102786
+ * returns its exit code: 1 when it found anything of high severity or could
102787
+ * not run, 0 otherwise, and 2 when the arguments make no sense to it.
102788
+ */
102789
+ async function audit(args, host) {
102790
+ if (args.length === 1 && (args[0] === "-h" || args[0] === "--help")) {
102791
+ host.stdout(USAGE$4);
102792
+ return 0;
102793
+ }
102794
+ const json = args.includes("--json");
102795
+ const unknown = args.find((arg) => arg !== "--json");
102796
+ if (unknown !== void 0) {
102797
+ host.stderr(`fiverunner: unknown argument ${unknown}\n\n${USAGE$4}`);
102798
+ return 2;
102799
+ }
102800
+ const project = openProject(host.cwd);
102801
+ if (project === void 0) {
102802
+ host.stderr("fiverunner: there is no FiveM project here: a resource has an fxmanifest.lua or a __resource.lua, and a server a resources directory\n");
102803
+ return 2;
102804
+ }
102805
+ const [cfxlua, sqlite, quickjs] = await Promise.all([
102806
+ host.cfxlua(),
102807
+ host.sqlite(),
102808
+ host.quickjs?.()
102809
+ ]);
102810
+ let open = 0;
102811
+ let tried = 0;
102812
+ const onLine = (line) => {
102813
+ const record = recordOf(line);
102814
+ if (record?.type === "start") {
102815
+ open = record.open;
102816
+ host.stderr(`Triggering the ${open} events that clients may trigger, of the ${record.events} that the server handles\n`);
102817
+ } else if (record?.type === "event" && record.open && ++tried % 10 === 0 && tried < open) host.stderr(` ${tried} of ${open}\n`);
102818
+ };
102819
+ const result = await runFile({
102820
+ path: PATH,
102821
+ source: audit_default
102822
+ }, {
102823
+ cfxlua,
102824
+ sqlite,
102825
+ project,
102826
+ onLine,
102827
+ ...quickjs === void 0 ? {} : { quickjs }
102828
+ });
102829
+ const records = recordsOf(result);
102830
+ if (json) host.stdout(`${JSON.stringify(records, null, 2)}\n`);
102831
+ else host.stdout(reportOf(records));
102832
+ if (!records.some((record) => record.type === "end")) {
102833
+ const failure = result.failure ?? result.tests[0]?.failures[0];
102834
+ host.stderr(`fiverunner: the audit did not end${failure === void 0 ? "" : `: ${failure.message}`}\n`);
102835
+ return 1;
102836
+ }
102837
+ return records.some((record) => record.type === "finding" && record.severity === "high") ? 1 : 0;
102838
+ }
102839
+ /** What the script of the audit told with a line that the server printed, if it told anything. */
102840
+ function recordOf({ process, text }) {
102841
+ if (process !== "server" || !text.startsWith(MARK)) return void 0;
102842
+ return JSON.parse(text.slice(17));
102843
+ }
102844
+ /** What the script of the audit told, from what the server printed as it ran. */
102845
+ function recordsOf(result) {
102846
+ return result.tests.flatMap(({ output }) => output.flatMap((line) => {
102847
+ const record = recordOf(line);
102848
+ return record === void 0 ? [] : [record];
102849
+ }));
102850
+ }
102851
+ /** The arguments that an event got, as a script would give them, if the finding has them. */
102852
+ function argumentsOf(args) {
102853
+ if (args === void 0) return "";
102854
+ return `(${args.map((arg) => typeof arg === "string" ? `'${arg}'` : JSON.stringify(arg)).join(", ")})`;
102855
+ }
102856
+ /** The report of an audit, as people read it. */
102857
+ function reportOf(records) {
102858
+ const lines = [];
102859
+ const start = records.find((record) => record.type === "start");
102860
+ const refused = records.find((record) => record.type === "refused");
102861
+ if (refused !== void 0) return `The server refused the player of the audit${refused.reason === void 0 ? "" : `: ${refused.reason}`}\n`;
102862
+ if (start !== void 0) {
102863
+ const framework = start.framework === void 0 ? "no framework" : `the framework ${start.framework}`;
102864
+ lines.push(`The server runs ${framework}${start.framework === void 0 ? "" : start.loggedIn ? ", with a character for the player" : ", but the player has no character"}.`);
102865
+ }
102866
+ const findings = records.filter((record) => record.type === "finding");
102867
+ for (const severity of [
102868
+ "high",
102869
+ "medium",
102870
+ "low"
102871
+ ]) {
102872
+ const found = findings.filter((finding) => finding.severity === severity);
102873
+ if (found.length === 0) continue;
102874
+ lines.push("", HEADINGS[severity]);
102875
+ for (const finding of found) lines.push(` ${finding.resources.join(", ")} ${finding.event}${argumentsOf(finding.args)} ${finding.effects.join("; ")}`);
102876
+ }
102877
+ const dropped = records.filter((record) => record.type === "dropped");
102878
+ if (dropped.length > 0) {
102879
+ lines.push("", "What dropped the player, or logged it out");
102880
+ for (const record of dropped) lines.push(` ${record.resources.join(", ")} ${record.event}${argumentsOf(record.args)}`);
102881
+ }
102882
+ const events = records.filter((record) => record.type === "event");
102883
+ const open = events.filter((record) => record.open).length;
102884
+ const count = (severity) => findings.filter((finding) => finding.severity === severity).length;
102885
+ lines.push("", `Events: ${events.length}, of which clients may trigger ${open}. Findings: ${count("high")} high, ${count("medium")} medium, ${count("low")} low.`);
102886
+ return `${lines.join("\n")}\n`;
102887
+ }
102888
+ //#endregion
102889
+ //#region src/dev/hosts.ts
102890
+ /**
102891
+ * The domain of the dev UI, which browsers take for the local machine, as
102892
+ * they do every name under localhost. Frames are at names under it, an
102893
+ * origin for each host of FiveM's that a client has frames at, so that
102894
+ * frames keep their storage to themselves and stay out of the UI, as in
102895
+ * FiveM, where each is at an origin of its own: https://cfx-nui-<resource>,
102896
+ * or nui://<resource>.
102897
+ */
102898
+ var DEV_DOMAIN = "fiverunner.localhost";
102899
+ new TextEncoder();
102900
+ var decoder$1 = new TextDecoder();
102901
+ /** What a name of frameHost stands for, or undefined for other names. */
102902
+ function parseFrameHost(name) {
102903
+ const match = /^(.+)\.c([1-9][0-9]*)\.([a-z0-9]+)\.fiverunner\.localhost$/.exec(name);
102904
+ if (match === null) return void 0;
102905
+ const [, label = "", number = "", tab = ""] = match;
102906
+ const client = Number(number);
102907
+ if (/^[a-z0-9_-]+$/.test(label)) return {
102908
+ host: label,
102909
+ client,
102910
+ tab
102911
+ };
102912
+ const hex = /^((?:[0-9a-f]{2})+)\.x$/.exec(label)?.[1];
102913
+ if (hex === void 0) return void 0;
102914
+ const bytes = Uint8Array.from(hex.match(/../g), (pair) => parseInt(pair, 16));
102915
+ return {
102916
+ host: decoder$1.decode(bytes),
102917
+ client,
102918
+ tab
102919
+ };
102920
+ }
102921
+ //#endregion
102922
+ //#region src/dev/remote.ts
102923
+ /** Where the dev server serves what the dev UI needs of it. */
102924
+ var API = "/__fiverunner/api/";
102925
+ new TextDecoder();
102926
+ /** The bytes in base 64. */
102927
+ function toBase64(bytes) {
102928
+ let text = "";
102929
+ for (let at = 0; at < bytes.length; at += 32768) text += String.fromCharCode(...bytes.subarray(at, at + 32768));
102930
+ return btoa(text);
102931
+ }
102932
+ /** The bytes that `text` has in base 64. */
102933
+ function fromBase64(text) {
102934
+ return Uint8Array.from(atob(text), (char) => char.charCodeAt(0));
102935
+ }
102936
+ //#endregion
102937
+ //#region src/dev/routes.ts
102938
+ /** Where the dev server serves what the frames of the dev UI run on: its service worker, and the bridge to the UI. */
102939
+ var DEV_PATH = "/__fiverunner/";
102940
+ //#endregion
102941
+ //#region src/node/watch.ts
102942
+ /** Milliseconds that the watcher waits for changes to stop before it tells of them. */
102943
+ var SETTLE = 150;
102944
+ /**
102945
+ * Watches the files of the project at `root`, but those of node_modules
102946
+ * and of dot directories, and calls `changed` with the paths of those that
102947
+ * changed, relative to the root, with slashes, once changes stop for a
102948
+ * moment, or with none when the host did not tell which. Gives what stops
102949
+ * watching.
102950
+ */
102951
+ function watchProject(root, changed) {
102952
+ let timer;
102953
+ let paths = /* @__PURE__ */ new Set();
102954
+ const heard = (file) => {
102955
+ const parts = (file ?? "").split(/[\\/]/);
102956
+ if (parts.some((part) => part === "node_modules" || part.startsWith("."))) return;
102957
+ if (file === null) paths = void 0;
102958
+ else paths?.add(parts.join("/"));
102959
+ clearTimeout(timer);
102960
+ timer = setTimeout(() => {
102961
+ const told = paths === void 0 ? void 0 : [...paths].sort();
102962
+ paths = /* @__PURE__ */ new Set();
102963
+ changed(told);
102964
+ }, SETTLE);
102965
+ };
102966
+ let watcher;
102967
+ try {
102968
+ watcher = watch(root, { recursive: true }, (_, file) => heard(file));
102969
+ watcher.on("error", () => {});
102970
+ } catch {
102971
+ watcher = void 0;
102972
+ }
102973
+ return () => {
102974
+ clearTimeout(timer);
102975
+ watcher?.close();
102976
+ };
102977
+ }
102978
+ //#endregion
102979
+ //#region src/node/devserver.ts
102980
+ /** The port that the dev server listens on unless told otherwise, or the next that is free. */
102981
+ var DEFAULT_PORT = 30120;
102982
+ /** The types of the files of the dev UI, by their extensions. */
102983
+ var TYPES$1 = {
102984
+ html: "text/html; charset=utf-8",
102985
+ js: "text/javascript; charset=utf-8",
102986
+ css: "text/css; charset=utf-8",
102987
+ json: "application/json",
102988
+ wasm: "application/wasm",
102989
+ svg: "image/svg+xml",
102990
+ png: "image/png"
102991
+ };
102992
+ /**
102993
+ * Starts the dev server of a project, which serves the dev UI, at
102994
+ * fiverunner.localhost, and what it asks of the server: the project and its
102995
+ * files, but its test files, cfxlua.wasm, and the HTTP requests of its
102996
+ * scripts, which the server sends; and the service worker and the bridge
102997
+ * of the origins of frames, at the names under it. It tells the dev UIs as
102998
+ * files of the project change. It listens on the local machine only, and
102999
+ * answers only requests for the names of the local machine, which keeps
103000
+ * pages of the web from reading through it.
103001
+ */
103002
+ async function startDevServer(options) {
103003
+ let project = openProject(options.root);
103004
+ if (project === void 0) throw new Error(`${options.root} has no FiveM project`);
103005
+ const http = options.http ?? fetchHttpClient;
103006
+ const listeners = /* @__PURE__ */ new Set();
103007
+ let port = 0;
103008
+ const handle = async (request, response) => {
103009
+ const name = /^(.*?)(?::(\d+))?$/.exec(request.headers.host ?? "");
103010
+ const hostname = (name?.[1] ?? "").toLowerCase();
103011
+ const url = new URL(request.url ?? "/", "http://local");
103012
+ if (name?.[2] !== String(port)) return reply(response, 403, "FiveRunner serves the local machine only.");
103013
+ if (hostname === "localhost" || hostname === "127.0.0.1" || hostname === "[::1]") {
103014
+ response.writeHead(302, { Location: `http://${DEV_DOMAIN}:${port}${url.pathname}${url.search}` });
103015
+ return response.end();
103016
+ }
103017
+ if (hostname === "fiverunner.localhost") return ui(request, response, url);
103018
+ if (parseFrameHost(hostname) !== void 0) return frames(response, url);
103019
+ return reply(response, 403, "FiveRunner serves the local machine only.");
103020
+ };
103021
+ /** Answers the requests of the dev UI. */
103022
+ const ui = async (request, response, url) => {
103023
+ const { pathname } = url;
103024
+ if (pathname === "/") return asset(response, "index.html");
103025
+ if (!pathname.startsWith("/__fiverunner/api/")) {
103026
+ if (pathname.startsWith("/__fiverunner/")) return asset(response, pathname.slice(DEV_PATH.length));
103027
+ return reply(response, 404, "Not found");
103028
+ }
103029
+ const what = pathname.slice(API.length);
103030
+ if (what === "events" && request.method === "GET") {
103031
+ response.writeHead(200, {
103032
+ "Content-Type": "text/event-stream",
103033
+ "Cache-Control": "no-cache",
103034
+ Connection: "keep-alive"
103035
+ });
103036
+ response.write(": FiveRunner\n\n");
103037
+ listeners.add(response);
103038
+ request.on("close", () => listeners.delete(response));
103039
+ return;
103040
+ }
103041
+ if (request.headers["X-FiveRunner".toLowerCase()] !== "1") return reply(response, 403, "Forbidden");
103042
+ const current = project;
103043
+ if (what === "project" && request.method === "GET") {
103044
+ const info = {
103045
+ name: basename(options.root),
103046
+ kind: current.kind,
103047
+ mount: current.mount,
103048
+ commands: current.commands
103049
+ };
103050
+ return reply(response, 200, JSON.stringify(info), TYPES$1.json);
103051
+ }
103052
+ if ((what === "list" || what === "read") && request.method === "GET") {
103053
+ const path = url.searchParams.get("path") ?? "";
103054
+ try {
103055
+ if (!listed(current.visible, path)) return reply(response, 404, "Not found");
103056
+ if (what === "list") return reply(response, 200, JSON.stringify(current.visible.list(path)), TYPES$1.json);
103057
+ return reply(response, 200, current.visible.read(path), "application/octet-stream");
103058
+ } catch {
103059
+ return reply(response, 404, "Not found");
103060
+ }
103061
+ }
103062
+ if (what === "cfxlua.wasm" && request.method === "GET") return reply(response, 200, options.cfxlua, TYPES$1.wasm);
103063
+ if (what === "http" && request.method === "POST") {
103064
+ const proxied = JSON.parse(await text(request));
103065
+ const result = await http({
103066
+ ...proxied,
103067
+ body: fromBase64(proxied.body)
103068
+ });
103069
+ const answered = "code" in result ? result : {
103070
+ status: result.status,
103071
+ headers: result.headers,
103072
+ body: toBase64(result.body)
103073
+ };
103074
+ return reply(response, 200, JSON.stringify(answered), TYPES$1.json);
103075
+ }
103076
+ return reply(response, 404, "Not found");
103077
+ };
103078
+ /** Answers the requests that reach the server at the origins of frames, which their service workers leave to it. */
103079
+ const frames = (response, url) => {
103080
+ const { pathname } = url;
103081
+ if (pathname.startsWith("/__fiverunner/")) {
103082
+ const path = pathname.slice(DEV_PATH.length);
103083
+ return asset(response, path, path === "sw.js" ? { "Service-Worker-Allowed": "/" } : {});
103084
+ }
103085
+ return reply(response, 503, "This frame of FiveRunner's NUI loads through its dev UI.");
103086
+ };
103087
+ /** Serves a file of the dev UI, by a path of a URL, which has no dot parts and stays in the assets. */
103088
+ const asset = (response, path, headers = {}) => {
103089
+ let bytes;
103090
+ try {
103091
+ bytes = readFileSync(join(options.assets, path));
103092
+ } catch {
103093
+ return reply(response, 404, "Not found");
103094
+ }
103095
+ const type = TYPES$1[path.slice(path.lastIndexOf(".") + 1)] ?? "application/octet-stream";
103096
+ return reply(response, 200, bytes, type, headers);
103097
+ };
103098
+ const server = createServer((request, response) => {
103099
+ handle(request, response).catch((error) => {
103100
+ if (!response.headersSent) reply(response, 500, String(error));
103101
+ else response.end();
103102
+ });
103103
+ });
103104
+ port = await listen(server, options.port ?? 30120);
103105
+ const stopWatching = watchProject(options.root, (paths) => {
103106
+ project = openProject(options.root) ?? project;
103107
+ const data = JSON.stringify(paths ?? null);
103108
+ for (const listener of listeners) listener.write(`event: change
103109
+ data: ${data}
103110
+
103111
+ `);
103112
+ options.onChange?.();
103113
+ });
103114
+ return {
103115
+ url: `http://${DEV_DOMAIN}:${port}/`,
103116
+ port,
103117
+ close: () => {
103118
+ stopWatching();
103119
+ for (const listener of listeners) listener.end();
103120
+ return new Promise((resolve) => server.close(() => resolve()));
103121
+ }
103122
+ };
103123
+ }
103124
+ /**
103125
+ * Whether the directories of `source` list `path`, and those it is in, as
103126
+ * sources list what they read: which no path does with test files, or with
103127
+ * parts that are empty, dots, or drives, which would lead out of the
103128
+ * project. Parts of paths that are files fail to list.
103129
+ */
103130
+ function listed(source, path) {
103131
+ if (path === "") return true;
103132
+ let directory = "";
103133
+ for (const part of path.split("/")) {
103134
+ if (!source.list(directory).some((entry) => entry.name === part)) return false;
103135
+ directory = directory === "" ? part : `${directory}/${part}`;
103136
+ }
103137
+ return true;
103138
+ }
103139
+ /** Answers with `status`, `body`, and the type, as what the dev UI gets is never cached. */
103140
+ function reply(response, status, body, type = "text/plain; charset=utf-8", headers = {}) {
103141
+ response.writeHead(status, {
103142
+ "Content-Type": type,
103143
+ "Cache-Control": "no-cache",
103144
+ ...headers
103145
+ });
103146
+ response.end(body);
103147
+ }
103148
+ /** The body of a request, as text. */
103149
+ async function text(request) {
103150
+ const chunks = [];
103151
+ for await (const chunk of request) chunks.push(chunk);
103152
+ return Buffer.concat(chunks).toString("utf8");
103153
+ }
103154
+ /** Listens on `port` of the local machine, or on the next ports while those are taken, and resolves with the port. */
103155
+ function listen(server, port) {
103156
+ return new Promise((resolve, reject) => {
103157
+ const attempt = (at) => {
103158
+ const failed = (error) => {
103159
+ server.off("listening", listening);
103160
+ if (error.code === "EADDRINUSE" && at !== 0 && at < port + 20) attempt(at + 1);
103161
+ else reject(error);
103162
+ };
103163
+ const listening = () => {
103164
+ server.off("error", failed);
103165
+ resolve(server.address().port);
103166
+ };
103167
+ server.once("error", failed);
103168
+ server.once("listening", listening);
103169
+ server.listen(at, "127.0.0.1");
103170
+ };
103171
+ attempt(port);
103172
+ });
103173
+ }
103174
+ //#endregion
103175
+ //#region src/cli/dev.ts
103176
+ var USAGE$3 = `Usage: fiverunner dev [options]
103177
+
103178
+ Runs the project in this directory in the dev UI, in the browser: its
103179
+ server, with its console and its resources, and players that connect to
103180
+ it, each with a screen that shows the frames of its NUI. The dev UI
103181
+ restarts the resources whose files change. It needs a browser that takes the
103182
+ names under localhost for the local machine, as Chrome, Edge and Firefox do.
103183
+
103184
+ Options:
103185
+ -p, --port <port> Listen on this port, or the next free one (${DEFAULT_PORT} by default)
103186
+ -h, --help Show this help
103187
+ `;
103188
+ /**
103189
+ * Runs the dev server of the project in the directory that the tool runs
103190
+ * in until the tool is interrupted, and returns the exit code: 0 once it
103191
+ * stopped, and 2 if the arguments make no sense to it, or there is no
103192
+ * project.
103193
+ */
103194
+ async function dev(args, host) {
103195
+ if (args.length === 1 && (args[0] === "-h" || args[0] === "--help")) {
103196
+ host.stdout(USAGE$3);
103197
+ return 0;
103198
+ }
103199
+ const fail = (why) => {
103200
+ host.stderr(`fiverunner: ${why}\n\n${USAGE$3}`);
103201
+ return 2;
103202
+ };
103203
+ let port = DEFAULT_PORT;
103204
+ for (let at = 0; at < args.length; at++) {
103205
+ const arg = args[at];
103206
+ if (arg !== "-p" && arg !== "--port") return fail(`unknown argument ${arg}`);
103207
+ const value = args[++at];
103208
+ if (value === void 0 || !/^\d{1,5}$/.test(value) || Number(value) > 65535) return fail(`${arg} needs a port, from 0 to 65535`);
103209
+ port = Number(value);
103210
+ }
103211
+ const project = openProject(host.cwd);
103212
+ if (project === void 0) {
103213
+ host.stderr("fiverunner: there is no FiveM project here: a resource has an fxmanifest.lua or a __resource.lua, and a server a resources directory\n");
103214
+ return 2;
103215
+ }
103216
+ if (!existsSync(join(host.assets, "index.html"))) {
103217
+ host.stderr(`fiverunner: the dev UI is not built: there is no ${join(host.assets, "index.html")}\n`);
103218
+ return 2;
103219
+ }
103220
+ const server = await startDevServer({
103221
+ root: host.cwd,
103222
+ assets: host.assets,
103223
+ cfxlua: host.wasm(),
103224
+ port,
103225
+ onChange: () => host.stdout("Files changed: the dev UI restarts the resources they are in\n")
103226
+ });
103227
+ const name = project.kind === "resource" ? `the resource ${project.name}` : "the server";
103228
+ host.stdout(`FiveRunner runs ${name} at ${server.url}\nPress Ctrl+C to stop\n`);
103229
+ await host.interrupted();
103230
+ await server.close();
103231
+ return 0;
103232
+ }
103233
+ //#endregion
103234
+ //#region src/cli/types.lua?raw
103235
+ var types_default = "---@meta\n\n-- What the tests of FiveRunner have, for the Lua language server:\n-- https://github.com/DariusHQ/fiverunner/blob/main/docs/testing.md\n\n---@class Identity\n---@field name? string\n---@field guid? string\n---@field endpoint? string\n---@field identifiers? string[]\n---@field tokens? string[]\n---@field card? table What the player answers the adaptive cards of deferrals with\n\n---@class Player\n---@field id integer The id of the player on the server, its source\n---@field name string\nlocal Player = {}\n\n--- Runs Lua code in the client of the player, with `...` as its arguments, and returns what it returns.\n---@param code string\n---@param ... any\n---@return any ...\nfunction Player:run(code, ...) end\n\n--- Triggers an event on the server from the client, and returns once the server got it.\n---@param event string\n---@param ... any\nfunction Player:emit(event, ...) end\n\n--- The arguments of each `event` that the client got from the server, in the order they came.\n---@param event string\n---@return any[][]\nfunction Player:received(event) end\n\n--- What the client printed, line by line, as `[channel] text`, without colors, from line `from` on, when given.\n---@param from? integer\n---@return string[]\nfunction Player:output(from) end\n\n--- Disconnects the player, and returns once the server dropped it.\n---@param reason? string\nfunction Player:disconnect(reason) end\n\n--- What the client sent the NUI frame of `resource` with SendNUIMessage, in the order it sent them.\n---@param resource string\n---@return table[]\nfunction Player:nuiMessages(resource) end\n\n--- Calls a NUI callback, as the page of the resource does, and returns what it answered, and the status.\n---@param resource string\n---@param name string\n---@param data? table\n---@return any answer\n---@return integer status\nfunction Player:nuiCallback(resource, name, data) end\n\n--- Whether NUI has the focus on the client, and whether it shows the cursor.\n---@return boolean focused\n---@return boolean cursor\nfunction Player:nuiFocus() end\n\n--- Has a key of the keyboard of the client, such as `E` or `LMENU`, go down.\n---@param key string\nfunction Player:keyDown(key) end\n\n--- Has a key of the keyboard of the client go up.\n---@param key string\nfunction Player:keyUp(key) end\n\n--- Has a key go down, and up a frame later.\n---@param key string\nfunction Player:press(key) end\n\n--- Mocks a native on the client, for the rest of the test.\n---@param name string\n---@param fn function\nfunction Player:mock(name, fn) end\n\n---@class Expectation\n---@field never Expectation Negates the matchers\n---@field toBe fun(expected: any)\n---@field toEqual fun(expected: any)\n---@field toBeTruthy fun()\n---@field toBeFalsy fun()\n---@field toBeNil fun()\n---@field toBeGreaterThan fun(n: number)\n---@field toBeGreaterThanOrEqual fun(n: number)\n---@field toBeLessThan fun(n: number)\n---@field toBeLessThanOrEqual fun(n: number)\n---@field toBeCloseTo fun(n: number, digits?: integer)\n---@field toContain fun(item: any)\n---@field toContainEqual fun(item: any)\n---@field toHaveLength fun(n: integer)\n---@field toMatch fun(pattern: string)\n---@field toThrow fun(text?: string)\n\n---@class fiverunnerlib\nfiverunner = {}\n\n--- Connects a player, and returns it once its client started the resources of the server, or nil and why the server refused it.\n---@param identity? Identity\n---@return Player? player\n---@return string? reason\nfunction fiverunner.connect(identity) end\n\n--- What the server printed, line by line, as `[channel] text`, without colors, from line `from` on, when given.\n---@param from? integer\n---@return string[]\nfunction fiverunner.output(from) end\n\n--- The events that the resources of the server handle, in the order they first registered for them, those of tests left out.\n---@return { name: string, resources: string[] }[]\nfunction fiverunner.events() end\n\n--- Calls `check` at each tick until it returns something truthy, and returns that.\n---@generic T\n---@param check fun(): T\n---@param timeout? integer In milliseconds\n---@return T\nfunction fiverunner.waitFor(check, timeout) end\n\n--- Has `fn` run in place of a native on the server, for every resource, for the rest of the test.\n---@param name string\n---@param fn function\nfunction fiverunner.mock(name, fn) end\n\n--- Groups tests.\n---@param name string\n---@param fn fun()\nfunction describe(name, fn) end\n\n--- Adds a test, which fails if it does not end within `timeout` milliseconds: 5000 unless given.\n---@param name string\n---@param fn fun()\n---@param timeout? integer\nfunction it(name, fn, timeout) end\n\n--- Adds a test.\n---@param name string\n---@param fn fun()\n---@param timeout? integer\nfunction test(name, fn, timeout) end\n\n--- Adds a hook that runs before each test of the describe it is in, or of the file.\n---@param fn fun()\nfunction beforeEach(fn) end\n\n--- Adds a hook that runs after each test of the describe it is in, or of the file.\n---@param fn fun()\nfunction afterEach(fn) end\n\n--- Checks a value with a matcher, which is called with a dot.\n---@param value any\n---@return Expectation\nfunction expect(value) end\n";
103236
+ //#endregion
103237
+ //#region src/cli/init.ts
103238
+ var USAGE$2 = `Usage: fiverunner init
103239
+
103240
+ Adds tests to the project in this directory: a test file, which checks
103241
+ that the project starts without script errors, what the Lua language
103242
+ server needs to know the functions of tests, and, at the root of a git
103243
+ repository, a GitHub Actions workflow that runs the tests on each push.
103244
+ It writes over nothing that is there.
103245
+
103246
+ Options:
103247
+ -h, --help Show this help
103248
+ `;
103249
+ /** The workflow of GitHub Actions that runs the tests of the project on each push. */
103250
+ var WORKFLOW = `name: Tests
103251
+
103252
+ on: [push, pull_request]
103253
+
103254
+ jobs:
103255
+ test:
103256
+ runs-on: ubuntu-latest
103257
+ steps:
103258
+ - uses: actions/checkout@v7
103259
+ - uses: DariusHQ/fiverunner-action@v1
103260
+ `;
103261
+ /** Where the types of tests go, which the settings of the Lua language server name. */
103262
+ var TYPES = ".fiverunner/fiverunner.lua";
103263
+ var SETTINGS = `${JSON.stringify({
103264
+ "runtime.version": "Lua 5.4",
103265
+ "workspace.library": [".fiverunner"]
103266
+ }, null, 2)}\n`;
103267
+ /** A test file of `subject`, a Lua expression of the name of the tests, whose test checks the start of the project, on the server and on the client of a player, and what `checks` says. */
103268
+ function testFile(subject, checks) {
103269
+ return `-- The tests of the project, which \`npx fiverunner test\` runs:
103270
+ -- https://github.com/DariusHQ/fiverunner/blob/main/docs/testing.md
103271
+
103272
+ local function errors(output)
103273
+ local found = {}
103274
+ for _, line in ipairs(output) do
103275
+ if line:find('SCRIPT ERROR', 1, true) or line:find('Failed to load script', 1, true) then
103276
+ found[#found + 1] = line
103277
+ end
103278
+ end
103279
+ return found
103280
+ end
103281
+
103282
+ describe(${subject}, function()
103283
+ it('starts without script errors', function()
103284
+ ${checks} -- Players reach servers once they started, which takes the big ones seconds.
103285
+ while GetGameTimer() < 3000 do Wait(100) end
103286
+ local player, refused = fiverunner.connect({ name = 'Ann' })
103287
+ expect(refused).toBeNil()
103288
+ expect(errors(fiverunner.output())).toEqual({})
103289
+ expect(errors(player:output())).toEqual({})
103290
+ end, 120000)
103291
+ end)
103292
+ `;
103293
+ }
103294
+ /**
103295
+ * Adds a test file and a workflow to the project in the directory that
103296
+ * the tool runs in, and returns the exit code: 0 once it did, and 2 if the
103297
+ * arguments make no sense to it, or there is no project.
103298
+ */
103299
+ function init(args, host) {
103300
+ if (args.length === 1 && (args[0] === "-h" || args[0] === "--help")) {
103301
+ host.stdout(USAGE$2);
103302
+ return 0;
103303
+ }
103304
+ if (args.length > 0) {
103305
+ host.stderr(`fiverunner: unknown argument ${args[0]}\n\n${USAGE$2}`);
103306
+ return 2;
103307
+ }
103308
+ const project = openProject(host.cwd);
103309
+ if (project === void 0) {
103310
+ host.stderr("fiverunner: there is no FiveM project here: a resource has an fxmanifest.lua or a __resource.lua, and a server a resources directory\n");
103311
+ return 2;
103312
+ }
103313
+ const files = project.kind === "resource" ? [[`tests/${project.name}.test.lua`, testFile(JSON.stringify(project.name), ` expect(GetResourceState(${JSON.stringify(project.name)})).toBe('started')\n`)]] : [["tests/server.test.lua", testFile("'the server'", "")]];
103314
+ files.push([TYPES, types_default], [".luarc.json", SETTINGS]);
103315
+ const repository = existsSync(join(host.cwd, ".git"));
103316
+ if (repository) files.push([".github/workflows/fiverunner.yml", WORKFLOW]);
103317
+ for (const [path, text] of files) {
103318
+ const at = join(host.cwd, path);
103319
+ if (existsSync(at)) {
103320
+ host.stdout(`Kept ${path}, which is there\n`);
103321
+ continue;
103322
+ }
103323
+ mkdirSync(dirname(at), { recursive: true });
103324
+ writeFileSync(at, text);
103325
+ host.stdout(`Wrote ${path}\n`);
103326
+ }
103327
+ if (!repository) host.stdout("Wrote no GitHub workflow, as GitHub looks for them at the root of a git repository, which this is not\n");
103328
+ host.stdout("Run the tests with npx fiverunner test\n");
103329
+ return 0;
103330
+ }
103331
+ //#endregion
103183
103332
  //#region src/cli/junit.ts
103184
103333
  /** What XML 1.0 takes of `text`, with what it reads as markup escaped, for attributes and text alike. */
103185
103334
  function escaped(text) {
@@ -103535,6 +103684,7 @@ Commands:
103535
103684
  test [paths...] Run the tests of the project in this directory
103536
103685
  dev Run the project in this directory in the dev UI, in the browser
103537
103686
  init Add a test file and a GitHub workflow to the project in this directory
103687
+ audit Trigger the events of the server as cheaters would, and tell what they change
103538
103688
 
103539
103689
  Options:
103540
103690
  -h, --help Show this help
@@ -103550,6 +103700,7 @@ async function main(args, host) {
103550
103700
  if (first === "test") return test(rest, host);
103551
103701
  if (first === "dev") return dev(rest, host);
103552
103702
  if (first === "init") return init(rest, host);
103703
+ if (first === "audit") return audit(rest, host);
103553
103704
  if (rest.length === 0 && (first === void 0 || first === "-h" || first === "--help")) {
103554
103705
  host.stdout(`FiveRunner ${package_default.version}\n\n${USAGE}`);
103555
103706
  return 0;