From 4024924529812c2310abd957f259bc6a5ced639d Mon Sep 17 00:00:00 2001 From: Linden <65407488+thelindat@users.noreply.github.com> Date: Fri, 24 Apr 2026 16:20:35 +1000 Subject: [PATCH 1/8] update name and refs --- CONTRIBUTING.md | 4 +- README.md | 16 +- fxmanifest.lua | 2 +- imports/locale/shared.lua | 2 +- package/README.md | 14 +- package/bun.lock | 1155 ++++++++++++++++++++++++++++------- package/package.json | 10 +- resource/init.lua | 2 +- resource/version/server.lua | 36 +- 9 files changed, 980 insertions(+), 261 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index f8873a318..0d56b4889 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -6,7 +6,7 @@ We welcome you to contribute to our projects, whether reporting a bug, suggestin ### Search for existing issues -Before creating a new issue, please search existing [issues](https://github.com/communityox/ox_lib/issues) to see if it has already been reported. +Before creating a new issue, please search existing [issues](https://github.com/overextended/ox_lib/issues) to see if it has already been reported. ### Add details to existing issues @@ -51,7 +51,7 @@ Pull requests that do not contribute meaningful improvements to the project's st - Fork the repository and, optionally, create a new branch for your changes. - If applicable, include example code to demonstrate your changes. - Ensure your code's style is consistent with the project, e.g. uses the same indentation and string quotations. -- If you have modified or introduced new APIs, open a pull request to our [documentation](https://github.com/communityox/docs). We will not accept undocumented code. +- If you have modified or introduced new APIs, open a pull request to our [documentation](https://github.com/overextended/docs). We will not accept undocumented code. ## Contributor License Agreement diff --git a/README.md b/README.md index 9a2bfc44f..db299af75 100644 --- a/README.md +++ b/README.md @@ -2,10 +2,10 @@ A FiveM library and resource implementing reusable modules, methods, and UI elements. -![](https://img.shields.io/github/downloads/communityox/ox_lib/total?logo=github) -![](https://img.shields.io/github/downloads/communityox/ox_lib/latest/total?logo=github) -![](https://img.shields.io/github/contributors/communityox/ox_lib?logo=github) -![](https://img.shields.io/github/v/release/communityox/ox_lib?logo=github) +![](https://img.shields.io/github/downloads/overextended/ox_lib/total?logo=github) +![](https://img.shields.io/github/downloads/overextended/ox_lib/latest/total?logo=github) +![](https://img.shields.io/github/contributors/overextended/ox_lib?logo=github) +![](https://img.shields.io/github/v/release/overextended/ox_lib?logo=github) For guidelines to contributing to the project, and to see our Contributor License Agreement, see [CONTRIBUTING.md](./CONTRIBUTING.md) @@ -14,19 +14,19 @@ For additional legal notices, refer to [NOTICE.md](./NOTICE.md). ## 📚 Documentation -https://coxdocs.dev/ox_lib +https://overextended.dev/ox_lib ## 💾 Download -https://github.com/communityox/ox_lib/releases/latest/download/ox_lib.zip +https://github.com/overextended/ox_lib/releases/latest/download/ox_lib.zip ## 📦 npm package -https://www.npmjs.com/package/@communityox/ox_lib +https://www.npmjs.com/package/@overextended/ox_lib ## 🖥️ Lua Language Server - Install [Lua Language Server](https://marketplace.visualstudio.com/items?itemName=sumneko.lua) to ease development with annotations, type checking, diagnostics, and more. -- Install [CfxLua IntelliSense](https://marketplace.visualstudio.com/items?itemName=communityox.cfxlua-vscode-cox) to add natives and cfxlua runtime declarations to LLS. +- Install [CfxLua IntelliSense](https://marketplace.visualstudio.com/items?itemName=overextended.cfxlua-vscode-cox) to add natives and cfxlua runtime declarations to LLS. - You can load ox_lib into your global development environment by modifying workspace/user settings "Lua.workspace.library" with the resource path. - e.g. "c:/fxserver/resources/ox_lib" diff --git a/fxmanifest.lua b/fxmanifest.lua index 0ae47c1ba..f5cb36fd9 100644 --- a/fxmanifest.lua +++ b/fxmanifest.lua @@ -8,7 +8,7 @@ name 'ox_lib' author 'Overextended' version '3.32.3' license 'LGPL-3.0-or-later' -repository 'https://github.com/communityox/ox_lib' +repository 'https://github.com/overextended/ox_lib' description 'A library of shared functions to utilise in other resources.' dependencies { diff --git a/imports/locale/shared.lua b/imports/locale/shared.lua index 7ee506ce7..0ba1a0028 100644 --- a/imports/locale/shared.lua +++ b/imports/locale/shared.lua @@ -59,7 +59,7 @@ end local table = lib.table ----Loads the ox_lib locale module. Prefer using fxmanifest instead (see [docs](https://coxdocs.dev/ox_lib#usage)). +---Loads the ox_lib locale module. Prefer using fxmanifest instead (see [docs](https://overextended.dev/ox_lib#usage)). ---@param key? string function lib.locale(key) local lang = key or lib.getLocaleKey() diff --git a/package/README.md b/package/README.md index d2ce3bb8e..7fc55398d 100644 --- a/package/README.md +++ b/package/README.md @@ -9,13 +9,13 @@ You still need to use and have the ox_lib resource included into the resource yo ```yaml # With pnpm -pnpm add @communityox/ox_lib +pnpm add @overextended/ox_lib # With Yarn -yarn add @communityox/ox_lib +yarn add @overextended/ox_lib # With npm -npm install @communityox/ox_lib +npm install @overextended/ox_lib ``` ## Usage @@ -23,16 +23,16 @@ You can either import the lib from client or server files or deconstruct the obj you may require. ```ts -import lib from '@communityox/ox_lib/client' +import lib from '@overextended/ox_lib/client' ``` ```ts -import lib from '@communityox/ox_lib/server' +import lib from '@overextended/ox_lib/server' ``` ```ts -import { checkDependency } from '@communityox/ox_lib/shared'; +import { checkDependency } from '@overextended/ox_lib/shared'; ``` ## Documentation -[View documentation](https://coxdocs.dev/ox_lib) \ No newline at end of file +[View documentation](https://overextended.dev/ox_lib) diff --git a/package/bun.lock b/package/bun.lock index c78fbe66b..fd1cad3a5 100644 --- a/package/bun.lock +++ b/package/bun.lock @@ -2,7 +2,7 @@ "lockfileVersion": 1, "workspaces": { "": { - "name": "@communityox/ox_lib", + "name": "@overextended/ox_lib", "dependencies": { "@nativewrappers/fivem": "^0.0.103", "csstype": "^3.1.3", @@ -21,222 +21,939 @@ }, }, "packages": { - "@babel/code-frame": ["@babel/code-frame@7.27.1", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.27.1", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg=="], - - "@babel/generator": ["@babel/generator@7.27.1", "", { "dependencies": { "@babel/parser": "^7.27.1", "@babel/types": "^7.27.1", "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.25", "jsesc": "^3.0.2" } }, "sha512-UnJfnIpc/+JO0/+KRVQNGU+y5taA5vCbwN8+azkX6beii/ZF+enZJSOKo11ZSzGJjlNfJHfQtmQT8H+9TXPG2w=="], - - "@babel/helper-module-imports": ["@babel/helper-module-imports@7.27.1", "", { "dependencies": { "@babel/traverse": "^7.27.1", "@babel/types": "^7.27.1" } }, "sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w=="], - - "@babel/helper-string-parser": ["@babel/helper-string-parser@7.27.1", "", {}, "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA=="], - - "@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.27.1", "", {}, "sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow=="], - - "@babel/parser": ["@babel/parser@7.27.1", "", { "dependencies": { "@babel/types": "^7.27.1" }, "bin": "./bin/babel-parser.js" }, "sha512-I0dZ3ZpCrJ1c04OqlNsQcKiZlsrXf/kkE4FXzID9rIOYICsAbA8mMDzhW/luRNAHdCNt7os/u8wenklZDlUVUQ=="], - - "@babel/runtime": ["@babel/runtime@7.27.1", "", {}, "sha512-1x3D2xEk2fRo3PAhwQwu5UubzgiVWSXTBfWpVd2Mx2AzRqJuDJCsgaDVZ7HB5iGzDW1Hl1sWN2mFyKjmR9uAog=="], - - "@babel/template": ["@babel/template@7.27.1", "", { "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/parser": "^7.27.1", "@babel/types": "^7.27.1" } }, "sha512-Fyo3ghWMqkHHpHQCoBs2VnYjR4iWFFjguTDEqA5WgZDOrFesVjMhMM2FSqTKSoUSDO1VQtavj8NFpdRBEvJTtg=="], - - "@babel/traverse": ["@babel/traverse@7.27.1", "", { "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.27.1", "@babel/parser": "^7.27.1", "@babel/template": "^7.27.1", "@babel/types": "^7.27.1", "debug": "^4.3.1", "globals": "^11.1.0" } }, "sha512-ZCYtZciz1IWJB4U61UPu4KEaqyfj+r5T1Q5mqPo+IBpcG9kHv30Z0aD8LXPgC1trYa6rK0orRyAhqUgk4MjmEg=="], - - "@babel/types": ["@babel/types@7.27.1", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.27.1" } }, "sha512-+EzkxvLNfiUeKMgy/3luqfsCWFRXLb7U6wNQTk60tovuckwB15B191tJWvpp4HjiQWdJkCxO3Wbvc6jlk3Xb2Q=="], - - "@citizenfx/client": ["@citizenfx/client@2.0.14758-1", "", {}, "sha512-J3V35hmT1qZWfIOhXU+EYQXuY5mpgGeGbqJwXab7lrSi61ph1eHa1dxT8PVJ5AobTZFswTzLJgy1Bnd2ePfygg=="], - - "@citizenfx/server": ["@citizenfx/server@2.0.14758-1", "", {}, "sha512-vPF77bnAI6cJzcAFoNT9k6r9hX1nYL8MPXZzwrUn9Ke6WOoxQIexQcff4ewWyz5cs4rmiPlb4MtWCUW0LrQxvQ=="], - - "@emotion/babel-plugin": ["@emotion/babel-plugin@11.13.5", "", { "dependencies": { "@babel/helper-module-imports": "^7.16.7", "@babel/runtime": "^7.18.3", "@emotion/hash": "^0.9.2", "@emotion/memoize": "^0.9.0", "@emotion/serialize": "^1.3.3", "babel-plugin-macros": "^3.1.0", "convert-source-map": "^1.5.0", "escape-string-regexp": "^4.0.0", "find-root": "^1.1.0", "source-map": "^0.5.7", "stylis": "4.2.0" } }, "sha512-pxHCpT2ex+0q+HH91/zsdHkw/lXd468DIN2zvfvLtPKLLMo6gQj7oLObq8PhkrxOZb/gGCq03S3Z7PDhS8pduQ=="], - - "@emotion/cache": ["@emotion/cache@11.14.0", "", { "dependencies": { "@emotion/memoize": "^0.9.0", "@emotion/sheet": "^1.4.0", "@emotion/utils": "^1.4.2", "@emotion/weak-memoize": "^0.4.0", "stylis": "4.2.0" } }, "sha512-L/B1lc/TViYk4DcpGxtAVbx0ZyiKM5ktoIyafGkH6zg/tj+mA+NE//aPYKG0k8kCHSHVJrpLpcAlOBEXQ3SavA=="], - - "@emotion/hash": ["@emotion/hash@0.9.2", "", {}, "sha512-MyqliTZGuOm3+5ZRSaaBGP3USLw6+EGykkwZns2EPC5g8jJ4z9OrdZY9apkl3+UP9+sdz76YYkwCKP5gh8iY3g=="], - - "@emotion/memoize": ["@emotion/memoize@0.9.0", "", {}, "sha512-30FAj7/EoJ5mwVPOWhAyCX+FPfMDrVecJAM+Iw9NRoSl4BBAQeqj4cApHHUXOVvIPgLVDsCFoz/hGD+5QQD1GQ=="], - - "@emotion/react": ["@emotion/react@11.14.0", "", { "dependencies": { "@babel/runtime": "^7.18.3", "@emotion/babel-plugin": "^11.13.5", "@emotion/cache": "^11.14.0", "@emotion/serialize": "^1.3.3", "@emotion/use-insertion-effect-with-fallbacks": "^1.2.0", "@emotion/utils": "^1.4.2", "@emotion/weak-memoize": "^0.4.0", "hoist-non-react-statics": "^3.3.1" }, "peerDependencies": { "react": ">=16.8.0" } }, "sha512-O000MLDBDdk/EohJPFUqvnp4qnHeYkVP5B0xEG0D/L7cOKP9kefu2DXn8dj74cQfsEzUqh+sr1RzFqiL1o+PpA=="], - - "@emotion/serialize": ["@emotion/serialize@1.3.3", "", { "dependencies": { "@emotion/hash": "^0.9.2", "@emotion/memoize": "^0.9.0", "@emotion/unitless": "^0.10.0", "@emotion/utils": "^1.4.2", "csstype": "^3.0.2" } }, "sha512-EISGqt7sSNWHGI76hC7x1CksiXPahbxEOrC5RjmFRJTqLyEK9/9hZvBbiYn70dw4wuwMKiEMCUlR6ZXTSWQqxA=="], - - "@emotion/sheet": ["@emotion/sheet@1.4.0", "", {}, "sha512-fTBW9/8r2w3dXWYM4HCB1Rdp8NLibOw2+XELH5m5+AkWiL/KqYX6dc0kKYlaYyKjrQ6ds33MCdMPEwgs2z1rqg=="], - - "@emotion/unitless": ["@emotion/unitless@0.10.0", "", {}, "sha512-dFoMUuQA20zvtVTuxZww6OHoJYgrzfKM1t52mVySDJnMSEa08ruEvdYQbhvyu6soU+NeLVd3yKfTfT0NeV6qGg=="], - - "@emotion/use-insertion-effect-with-fallbacks": ["@emotion/use-insertion-effect-with-fallbacks@1.2.0", "", { "peerDependencies": { "react": ">=16.8.0" } }, "sha512-yJMtVdH59sxi/aVJBpk9FQq+OR8ll5GT8oWd57UpeaKEVGab41JWaCFA7FRLoMLloOZF/c/wsPoe+bfGmRKgDg=="], - - "@emotion/utils": ["@emotion/utils@1.4.2", "", {}, "sha512-3vLclRofFziIa3J2wDh9jjbkUz9qk5Vi3IZ/FSTKViB0k+ef0fPV7dYrUIugbgupYDx7v9ud/SjrtEP8Y4xLoA=="], - - "@emotion/weak-memoize": ["@emotion/weak-memoize@0.4.0", "", {}, "sha512-snKqtPW01tN0ui7yu9rGv69aJXr/a/Ywvl11sUjNtEcRc+ng/mQriFL0wLXMef74iHa/EkftbDzU9F8iFbH+zg=="], - - "@floating-ui/core": ["@floating-ui/core@1.7.0", "", { "dependencies": { "@floating-ui/utils": "^0.2.9" } }, "sha512-FRdBLykrPPA6P76GGGqlex/e7fbe0F1ykgxHYNXQsH/iTEtjMj/f9bpY5oQqbjt5VgZvgz/uKXbGuROijh3VLA=="], - - "@floating-ui/dom": ["@floating-ui/dom@1.7.0", "", { "dependencies": { "@floating-ui/core": "^1.7.0", "@floating-ui/utils": "^0.2.9" } }, "sha512-lGTor4VlXcesUMh1cupTUTDoCxMb0V6bm3CnxHzQcw8Eaf1jQbgQX4i02fYgT0vJ82tb5MZ4CZk1LRGkktJCzg=="], - - "@floating-ui/react": ["@floating-ui/react@0.19.2", "", { "dependencies": { "@floating-ui/react-dom": "^1.3.0", "aria-hidden": "^1.1.3", "tabbable": "^6.0.1" }, "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-JyNk4A0Ezirq8FlXECvRtQOX/iBe5Ize0W/pLkrZjfHW9GUV7Xnq6zm6fyZuQzaHHqEnVizmvlA96e1/CkZv+w=="], - - "@floating-ui/react-dom": ["@floating-ui/react-dom@1.3.0", "", { "dependencies": { "@floating-ui/dom": "^1.2.1" }, "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-htwHm67Ji5E/pROEAr7f8IKFShuiCKHwUC/UY4vC3I5jiSvGFAYnSYiZO5MlGmads+QqvUkR9ANHEguGrDv72g=="], - - "@floating-ui/utils": ["@floating-ui/utils@0.2.9", "", {}, "sha512-MDWhGtE+eHw5JW7lq4qhc5yRLS11ERl1c7Z6Xd0a58DozHES6EnNNwUWbMiG4J9Cgj053Bhk8zvlhFYKVhULwg=="], - - "@fortawesome/fontawesome-common-types": ["@fortawesome/fontawesome-common-types@6.1.1", "", {}, "sha512-wVn5WJPirFTnzN6tR95abCx+ocH+3IFLXAgyavnf9hUmN0CfWoDjPT/BAWsUVwSlYYVBeCLJxaqi7ZGe4uSjBA=="], - - "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.8", "", { "dependencies": { "@jridgewell/set-array": "^1.2.1", "@jridgewell/sourcemap-codec": "^1.4.10", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-imAbBGkb+ebQyxKgzv5Hu2nmROxoDOXHh80evxdoXNOrvAnVx7zimzc1Oo5h9RlfV4vPXaE2iM5pOFbvOCClWA=="], - - "@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="], - - "@jridgewell/set-array": ["@jridgewell/set-array@1.2.1", "", {}, "sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A=="], - - "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.0", "", {}, "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ=="], - - "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.25", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ=="], - - "@mantine/core": ["@mantine/core@6.0.22", "", { "dependencies": { "@floating-ui/react": "^0.19.1", "@mantine/styles": "6.0.22", "@mantine/utils": "6.0.22", "@radix-ui/react-scroll-area": "1.0.2", "react-remove-scroll": "^2.5.5", "react-textarea-autosize": "8.3.4" }, "peerDependencies": { "@mantine/hooks": "6.0.22", "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-6kv0eY7n565fyjgS20qUYeCSxg3f1TJ5vurzbP1HHtFXXKSY0bYoqqDoHipFCt6NxsPQGeiC6cC0c/IWIlxoKQ=="], - - "@mantine/hooks": ["@mantine/hooks@6.0.22", "", { "peerDependencies": { "react": ">=16.8.0" } }, "sha512-e10//QTN2sAmC4Ryeu5X5L/TsxnrjXMOaGq3dxFPIPsCSwLzyxqySfjzVViWmoPWAj0Ak9MvE2MHFjzmOpA80w=="], - - "@mantine/styles": ["@mantine/styles@6.0.22", "", { "dependencies": { "clsx": "1.1.1", "csstype": "3.0.9" }, "peerDependencies": { "@emotion/react": ">=11.9.0", "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-Rud/IQp2EFYDiP4csRy2XBrho/Ct+W2/b+XbvCRTeQTmpFy/NfAKm/TWJa5zPvuv/iLTjGkVos9SHw/DteESpQ=="], - - "@mantine/utils": ["@mantine/utils@6.0.22", "", { "peerDependencies": { "react": ">=16.8.0" } }, "sha512-RSKlNZvxhMCkOFZ6slbYvZYbWjHUM+PxDQnupIOxIdsTZQQjx/BFfrfJ7kQFOP+g7MtpOds8weAetEs5obwMOQ=="], - - "@nativewrappers/fivem": ["@nativewrappers/fivem@0.0.103", "", {}, "sha512-x0W00Mx9ZN/rTS9XZc5Kf1hjahqRmlo9sPiuJP4kCYeQG4LDJyglXCsHcfNfygGq6WEblG1W2FLgm4MGDn/wHA=="], - - "@radix-ui/number": ["@radix-ui/number@1.0.0", "", { "dependencies": { "@babel/runtime": "^7.13.10" } }, "sha512-Ofwh/1HX69ZfJRiRBMTy7rgjAzHmwe4kW9C9Y99HTRUcYLUuVT0KESFj15rPjRgKJs20GPq8Bm5aEDJ8DuA3vA=="], - - "@radix-ui/primitive": ["@radix-ui/primitive@1.0.0", "", { "dependencies": { "@babel/runtime": "^7.13.10" } }, "sha512-3e7rn8FDMin4CgeL7Z/49smCA3rFYY3Ha2rUQ7HRWFadS5iCRw08ZgVT1LaNTCNqgvrUiyczLflrVrF0SRQtNA=="], - - "@radix-ui/react-compose-refs": ["@radix-ui/react-compose-refs@1.0.0", "", { "dependencies": { "@babel/runtime": "^7.13.10" }, "peerDependencies": { "react": "^16.8 || ^17.0 || ^18.0" } }, "sha512-0KaSv6sx787/hK3eF53iOkiSLwAGlFMx5lotrqD2pTjB18KbybKoEIgkNZTKC60YECDQTKGTRcDBILwZVqVKvA=="], - - "@radix-ui/react-context": ["@radix-ui/react-context@1.0.0", "", { "dependencies": { "@babel/runtime": "^7.13.10" }, "peerDependencies": { "react": "^16.8 || ^17.0 || ^18.0" } }, "sha512-1pVM9RfOQ+n/N5PJK33kRSKsr1glNxomxONs5c49MliinBY6Yw2Q995qfBUUo0/Mbg05B/sGA0gkgPI7kmSHBg=="], - - "@radix-ui/react-direction": ["@radix-ui/react-direction@1.0.0", "", { "dependencies": { "@babel/runtime": "^7.13.10" }, "peerDependencies": { "react": "^16.8 || ^17.0 || ^18.0" } }, "sha512-2HV05lGUgYcA6xgLQ4BKPDmtL+QbIZYH5fCOTAOOcJ5O0QbWS3i9lKaurLzliYUDhORI2Qr3pyjhJh44lKA3rQ=="], - - "@radix-ui/react-presence": ["@radix-ui/react-presence@1.0.0", "", { "dependencies": { "@babel/runtime": "^7.13.10", "@radix-ui/react-compose-refs": "1.0.0", "@radix-ui/react-use-layout-effect": "1.0.0" }, "peerDependencies": { "react": "^16.8 || ^17.0 || ^18.0", "react-dom": "^16.8 || ^17.0 || ^18.0" } }, "sha512-A+6XEvN01NfVWiKu38ybawfHsBjWum42MRPnEuqPsBZ4eV7e/7K321B5VgYMPv3Xx5An6o1/l9ZuDBgmcmWK3w=="], - - "@radix-ui/react-primitive": ["@radix-ui/react-primitive@1.0.1", "", { "dependencies": { "@babel/runtime": "^7.13.10", "@radix-ui/react-slot": "1.0.1" }, "peerDependencies": { "react": "^16.8 || ^17.0 || ^18.0", "react-dom": "^16.8 || ^17.0 || ^18.0" } }, "sha512-fHbmislWVkZaIdeF6GZxF0A/NH/3BjrGIYj+Ae6eTmTCr7EB0RQAAVEiqsXK6p3/JcRqVSBQoceZroj30Jj3XA=="], - - "@radix-ui/react-scroll-area": ["@radix-ui/react-scroll-area@1.0.2", "", { "dependencies": { "@babel/runtime": "^7.13.10", "@radix-ui/number": "1.0.0", "@radix-ui/primitive": "1.0.0", "@radix-ui/react-compose-refs": "1.0.0", "@radix-ui/react-context": "1.0.0", "@radix-ui/react-direction": "1.0.0", "@radix-ui/react-presence": "1.0.0", "@radix-ui/react-primitive": "1.0.1", "@radix-ui/react-use-callback-ref": "1.0.0", "@radix-ui/react-use-layout-effect": "1.0.0" }, "peerDependencies": { "react": "^16.8 || ^17.0 || ^18.0", "react-dom": "^16.8 || ^17.0 || ^18.0" } }, "sha512-k8VseTxI26kcKJaX0HPwkvlNBPTs56JRdYzcZ/vzrNUkDlvXBy8sMc7WvCpYzZkHgb+hd72VW9MqkqecGtuNgg=="], - - "@radix-ui/react-slot": ["@radix-ui/react-slot@1.0.1", "", { "dependencies": { "@babel/runtime": "^7.13.10", "@radix-ui/react-compose-refs": "1.0.0" }, "peerDependencies": { "react": "^16.8 || ^17.0 || ^18.0" } }, "sha512-avutXAFL1ehGvAXtPquu0YK5oz6ctS474iM3vNGQIkswrVhdrS52e3uoMQBzZhNRAIE0jBnUyXWNmSjGHhCFcw=="], - - "@radix-ui/react-use-callback-ref": ["@radix-ui/react-use-callback-ref@1.0.0", "", { "dependencies": { "@babel/runtime": "^7.13.10" }, "peerDependencies": { "react": "^16.8 || ^17.0 || ^18.0" } }, "sha512-GZtyzoHz95Rhs6S63D2t/eqvdFCm7I+yHMLVQheKM7nBD8mbZIt+ct1jz4536MDnaOGKIxynJ8eHTkVGVVkoTg=="], - - "@radix-ui/react-use-layout-effect": ["@radix-ui/react-use-layout-effect@1.0.0", "", { "dependencies": { "@babel/runtime": "^7.13.10" }, "peerDependencies": { "react": "^16.8 || ^17.0 || ^18.0" } }, "sha512-6Tpkq+R6LOlmQb1R5NNETLG0B4YP0wc+klfXafpUCj6JGyaUc8il7/kUZ7m59rGbXGczE9Bs+iz2qloqsZBduQ=="], - - "@types/node": ["@types/node@16.9.1", "", {}, "sha512-QpLcX9ZSsq3YYUUnD3nFDY8H7wctAhQj/TFKL8Ya8v5fMm3CFXxo8zStsLAl780ltoYoo1WvKUVGBQK+1ifr7g=="], - - "@types/parse-json": ["@types/parse-json@4.0.2", "", {}, "sha512-dISoDXWWQwUquiKsyZ4Ng+HX2KsPL7LyHKHQwgGFEA3IaKac4Obd+h2a/a6waisAoepJlBcx9paWqjA8/HVjCw=="], - - "@types/prop-types": ["@types/prop-types@15.7.14", "", {}, "sha512-gNMvNH49DJ7OJYv+KAKn0Xp45p8PLl6zo2YnvDIbTd4J6MER2BmWN49TG7n9LvkyihINxeKW8+3bfS2yDC9dzQ=="], - - "@types/react": ["@types/react@18.3.20", "", { "dependencies": { "@types/prop-types": "*", "csstype": "^3.0.2" } }, "sha512-IPaCZN7PShZK/3t6Q87pfTkRm6oLTd4vztyoj+cbHUF1g3FfVb2tFIL79uCRKEfv16AhqDMBywP2VW3KIZUvcg=="], - - "aria-hidden": ["aria-hidden@1.2.4", "", { "dependencies": { "tslib": "^2.0.0" } }, "sha512-y+CcFFwelSXpLZk/7fMB2mUbGtX9lKycf1MWJ7CaTIERyitVlyQx6C+sxcROU2BAJ24OiZyK+8wj2i8AlBoS3A=="], - - "babel-plugin-macros": ["babel-plugin-macros@3.1.0", "", { "dependencies": { "@babel/runtime": "^7.12.5", "cosmiconfig": "^7.0.0", "resolve": "^1.19.0" } }, "sha512-Cg7TFGpIr01vOQNODXOOaGz2NpCU5gl8x1qJFbb6hbZxR7XrcE2vtbAsTAbJ7/xwJtUuJEw8K8Zr/AE0LHlesg=="], - - "callsites": ["callsites@3.1.0", "", {}, "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ=="], - - "clsx": ["clsx@1.1.1", "", {}, "sha512-6/bPho624p3S2pMyvP5kKBPXnI3ufHLObBFCfgx+LkeR5lg2XYy2hqZqUf45ypD8COn2bhgGJSUE+l5dhNBieA=="], - - "convert-source-map": ["convert-source-map@1.9.0", "", {}, "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A=="], - - "cosmiconfig": ["cosmiconfig@7.1.0", "", { "dependencies": { "@types/parse-json": "^4.0.0", "import-fresh": "^3.2.1", "parse-json": "^5.0.0", "path-type": "^4.0.0", "yaml": "^1.10.0" } }, "sha512-AdmX6xUzdNASswsFtmwSt7Vj8po9IuqXm0UXz7QKPuEUmPB4XyjGfaAr2PSuELMwkRMVH1EpIkX5bTZGRB3eCA=="], - - "csstype": ["csstype@3.1.3", "", {}, "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="], - - "debug": ["debug@4.4.0", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA=="], - - "detect-node-es": ["detect-node-es@1.1.0", "", {}, "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ=="], - - "error-ex": ["error-ex@1.3.2", "", { "dependencies": { "is-arrayish": "^0.2.1" } }, "sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g=="], - - "escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="], - - "fast-printf": ["fast-printf@1.6.10", "", {}, "sha512-GwTgG9O4FVIdShhbVF3JxOgSBY2+ePGsu2V/UONgoCPzF9VY6ZdBMKsHKCYQHZwNk3qNouUolRDsgVxcVA5G1w=="], - - "find-root": ["find-root@1.1.0", "", {}, "sha512-NKfW6bec6GfKc0SGx1e07QZY9PE99u0Bft/0rzSD5k3sO/vwkVUpDUKVm5Gpp5Ue3YfShPFTX2070tDs5kB9Ng=="], - - "function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="], - - "get-nonce": ["get-nonce@1.0.1", "", {}, "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q=="], - - "globals": ["globals@11.12.0", "", {}, "sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA=="], - - "hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="], - - "hoist-non-react-statics": ["hoist-non-react-statics@3.3.2", "", { "dependencies": { "react-is": "^16.7.0" } }, "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw=="], - - "import-fresh": ["import-fresh@3.3.1", "", { "dependencies": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" } }, "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ=="], - - "is-arrayish": ["is-arrayish@0.2.1", "", {}, "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg=="], - - "is-core-module": ["is-core-module@2.16.1", "", { "dependencies": { "hasown": "^2.0.2" } }, "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w=="], - - "js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], - - "jsesc": ["jsesc@3.1.0", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="], - - "json-parse-even-better-errors": ["json-parse-even-better-errors@2.3.1", "", {}, "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w=="], - - "lines-and-columns": ["lines-and-columns@1.2.4", "", {}, "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg=="], - - "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], - - "parent-module": ["parent-module@1.0.1", "", { "dependencies": { "callsites": "^3.0.0" } }, "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g=="], - - "parse-json": ["parse-json@5.2.0", "", { "dependencies": { "@babel/code-frame": "^7.0.0", "error-ex": "^1.3.1", "json-parse-even-better-errors": "^2.3.0", "lines-and-columns": "^1.1.6" } }, "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg=="], - - "path-parse": ["path-parse@1.0.7", "", {}, "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw=="], - - "path-type": ["path-type@4.0.0", "", {}, "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw=="], - - "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], - - "prettier": ["prettier@2.8.8", "", { "bin": { "prettier": "bin-prettier.js" } }, "sha512-tdN8qQGvNjw4CHbY+XXk0JgCXn9QiF21a55rBe5LJAU+kDyC4WQn4+awm2Xfk2lQMk5fKup9XgzTZtGkjBdP9Q=="], - - "react": ["react@19.1.0", "", {}, "sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg=="], - - "react-dom": ["react-dom@19.1.0", "", { "dependencies": { "scheduler": "^0.26.0" }, "peerDependencies": { "react": "^19.1.0" } }, "sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g=="], - - "react-is": ["react-is@16.13.1", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="], - - "react-remove-scroll": ["react-remove-scroll@2.6.3", "", { "dependencies": { "react-remove-scroll-bar": "^2.3.7", "react-style-singleton": "^2.2.3", "tslib": "^2.1.0", "use-callback-ref": "^1.3.3", "use-sidecar": "^1.1.3" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-pnAi91oOk8g8ABQKGF5/M9qxmmOPxaAnopyTHYfqYEwJhyFrbbBtHuSgtKEoH0jpcxx5o3hXqH1mNd9/Oi+8iQ=="], - - "react-remove-scroll-bar": ["react-remove-scroll-bar@2.3.8", "", { "dependencies": { "react-style-singleton": "^2.2.2", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" }, "optionalPeers": ["@types/react"] }, "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q=="], - - "react-style-singleton": ["react-style-singleton@2.2.3", "", { "dependencies": { "get-nonce": "^1.0.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ=="], - - "react-textarea-autosize": ["react-textarea-autosize@8.3.4", "", { "dependencies": { "@babel/runtime": "^7.10.2", "use-composed-ref": "^1.3.0", "use-latest": "^1.2.1" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0" } }, "sha512-CdtmP8Dc19xL8/R6sWvtknD/eCXkQr30dtvC4VmGInhRsfF8X/ihXCq6+9l9qbxmKRiq407/7z5fxE7cVWQNgQ=="], - - "resolve": ["resolve@1.22.10", "", { "dependencies": { "is-core-module": "^2.16.0", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" } }, "sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w=="], - - "resolve-from": ["resolve-from@4.0.0", "", {}, "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g=="], - - "scheduler": ["scheduler@0.26.0", "", {}, "sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA=="], - - "source-map": ["source-map@0.5.7", "", {}, "sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ=="], - - "stylis": ["stylis@4.2.0", "", {}, "sha512-Orov6g6BB1sDfYgzWfTHDOxamtX1bE/zo104Dh9e6fqJ3PooipYyfJ0pUmrZO2wAvO8YbEyeFrkV91XTsGMSrw=="], - - "supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="], - - "tabbable": ["tabbable@6.2.0", "", {}, "sha512-Cat63mxsVJlzYvN51JmVXIgNoUokrIaT2zLclCXjRd8boZ0004U4KCs/sToJ75C6sdlByWxpYnb5Boif1VSFew=="], - - "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], - - "typescript": ["typescript@5.8.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ=="], - - "use-callback-ref": ["use-callback-ref@1.3.3", "", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg=="], - - "use-composed-ref": ["use-composed-ref@1.4.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-djviaxuOOh7wkj0paeO1Q/4wMZ8Zrnag5H6yBvzN7AKKe8beOaED9SF5/ByLqsku8NP4zQqsvM2u3ew/tJK8/w=="], - - "use-isomorphic-layout-effect": ["use-isomorphic-layout-effect@1.2.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-q6ayo8DWoPZT0VdG4u3D3uxcgONP3Mevx2i2b0434cwWBoL+aelL1DzkXI6w3PhTZzUeR2kaVlZn70iCiseP6w=="], - - "use-latest": ["use-latest@1.3.0", "", { "dependencies": { "use-isomorphic-layout-effect": "^1.1.1" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-mhg3xdm9NaM8q+gLT8KryJPnRFOz1/5XPBhmDEVZK1webPzDjrPk7f/mbpeLqTgB9msytYWANxgALOCJKnLvcQ=="], - - "use-sidecar": ["use-sidecar@1.1.3", "", { "dependencies": { "detect-node-es": "^1.1.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ=="], - - "yaml": ["yaml@1.10.2", "", {}, "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg=="], - - "@mantine/styles/csstype": ["csstype@3.0.9", "", {}, "sha512-rpw6JPxK6Rfg1zLOYCSwle2GFOOsnjmDYDaBwEcwoOg4qlsIVCN789VkBZDJAGi4T07gI4YSutR43t9Zz4Lzuw=="], - } + "@babel/code-frame": [ + "@babel/code-frame@7.27.1", + "", + { + "dependencies": { + "@babel/helper-validator-identifier": "^7.27.1", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1", + }, + }, + "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==", + ], + + "@babel/generator": [ + "@babel/generator@7.27.1", + "", + { + "dependencies": { + "@babel/parser": "^7.27.1", + "@babel/types": "^7.27.1", + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.25", + "jsesc": "^3.0.2", + }, + }, + "sha512-UnJfnIpc/+JO0/+KRVQNGU+y5taA5vCbwN8+azkX6beii/ZF+enZJSOKo11ZSzGJjlNfJHfQtmQT8H+9TXPG2w==", + ], + + "@babel/helper-module-imports": [ + "@babel/helper-module-imports@7.27.1", + "", + { "dependencies": { "@babel/traverse": "^7.27.1", "@babel/types": "^7.27.1" } }, + "sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w==", + ], + + "@babel/helper-string-parser": [ + "@babel/helper-string-parser@7.27.1", + "", + {}, + "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + ], + + "@babel/helper-validator-identifier": [ + "@babel/helper-validator-identifier@7.27.1", + "", + {}, + "sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow==", + ], + + "@babel/parser": [ + "@babel/parser@7.27.1", + "", + { "dependencies": { "@babel/types": "^7.27.1" }, "bin": "./bin/babel-parser.js" }, + "sha512-I0dZ3ZpCrJ1c04OqlNsQcKiZlsrXf/kkE4FXzID9rIOYICsAbA8mMDzhW/luRNAHdCNt7os/u8wenklZDlUVUQ==", + ], + + "@babel/runtime": [ + "@babel/runtime@7.27.1", + "", + {}, + "sha512-1x3D2xEk2fRo3PAhwQwu5UubzgiVWSXTBfWpVd2Mx2AzRqJuDJCsgaDVZ7HB5iGzDW1Hl1sWN2mFyKjmR9uAog==", + ], + + "@babel/template": [ + "@babel/template@7.27.1", + "", + { "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/parser": "^7.27.1", "@babel/types": "^7.27.1" } }, + "sha512-Fyo3ghWMqkHHpHQCoBs2VnYjR4iWFFjguTDEqA5WgZDOrFesVjMhMM2FSqTKSoUSDO1VQtavj8NFpdRBEvJTtg==", + ], + + "@babel/traverse": [ + "@babel/traverse@7.27.1", + "", + { + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@babel/generator": "^7.27.1", + "@babel/parser": "^7.27.1", + "@babel/template": "^7.27.1", + "@babel/types": "^7.27.1", + "debug": "^4.3.1", + "globals": "^11.1.0", + }, + }, + "sha512-ZCYtZciz1IWJB4U61UPu4KEaqyfj+r5T1Q5mqPo+IBpcG9kHv30Z0aD8LXPgC1trYa6rK0orRyAhqUgk4MjmEg==", + ], + + "@babel/types": [ + "@babel/types@7.27.1", + "", + { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.27.1" } }, + "sha512-+EzkxvLNfiUeKMgy/3luqfsCWFRXLb7U6wNQTk60tovuckwB15B191tJWvpp4HjiQWdJkCxO3Wbvc6jlk3Xb2Q==", + ], + + "@citizenfx/client": [ + "@citizenfx/client@2.0.14758-1", + "", + {}, + "sha512-J3V35hmT1qZWfIOhXU+EYQXuY5mpgGeGbqJwXab7lrSi61ph1eHa1dxT8PVJ5AobTZFswTzLJgy1Bnd2ePfygg==", + ], + + "@citizenfx/server": [ + "@citizenfx/server@2.0.14758-1", + "", + {}, + "sha512-vPF77bnAI6cJzcAFoNT9k6r9hX1nYL8MPXZzwrUn9Ke6WOoxQIexQcff4ewWyz5cs4rmiPlb4MtWCUW0LrQxvQ==", + ], + + "@emotion/babel-plugin": [ + "@emotion/babel-plugin@11.13.5", + "", + { + "dependencies": { + "@babel/helper-module-imports": "^7.16.7", + "@babel/runtime": "^7.18.3", + "@emotion/hash": "^0.9.2", + "@emotion/memoize": "^0.9.0", + "@emotion/serialize": "^1.3.3", + "babel-plugin-macros": "^3.1.0", + "convert-source-map": "^1.5.0", + "escape-string-regexp": "^4.0.0", + "find-root": "^1.1.0", + "source-map": "^0.5.7", + "stylis": "4.2.0", + }, + }, + "sha512-pxHCpT2ex+0q+HH91/zsdHkw/lXd468DIN2zvfvLtPKLLMo6gQj7oLObq8PhkrxOZb/gGCq03S3Z7PDhS8pduQ==", + ], + + "@emotion/cache": [ + "@emotion/cache@11.14.0", + "", + { + "dependencies": { + "@emotion/memoize": "^0.9.0", + "@emotion/sheet": "^1.4.0", + "@emotion/utils": "^1.4.2", + "@emotion/weak-memoize": "^0.4.0", + "stylis": "4.2.0", + }, + }, + "sha512-L/B1lc/TViYk4DcpGxtAVbx0ZyiKM5ktoIyafGkH6zg/tj+mA+NE//aPYKG0k8kCHSHVJrpLpcAlOBEXQ3SavA==", + ], + + "@emotion/hash": [ + "@emotion/hash@0.9.2", + "", + {}, + "sha512-MyqliTZGuOm3+5ZRSaaBGP3USLw6+EGykkwZns2EPC5g8jJ4z9OrdZY9apkl3+UP9+sdz76YYkwCKP5gh8iY3g==", + ], + + "@emotion/memoize": [ + "@emotion/memoize@0.9.0", + "", + {}, + "sha512-30FAj7/EoJ5mwVPOWhAyCX+FPfMDrVecJAM+Iw9NRoSl4BBAQeqj4cApHHUXOVvIPgLVDsCFoz/hGD+5QQD1GQ==", + ], + + "@emotion/react": [ + "@emotion/react@11.14.0", + "", + { + "dependencies": { + "@babel/runtime": "^7.18.3", + "@emotion/babel-plugin": "^11.13.5", + "@emotion/cache": "^11.14.0", + "@emotion/serialize": "^1.3.3", + "@emotion/use-insertion-effect-with-fallbacks": "^1.2.0", + "@emotion/utils": "^1.4.2", + "@emotion/weak-memoize": "^0.4.0", + "hoist-non-react-statics": "^3.3.1", + }, + "peerDependencies": { "react": ">=16.8.0" }, + }, + "sha512-O000MLDBDdk/EohJPFUqvnp4qnHeYkVP5B0xEG0D/L7cOKP9kefu2DXn8dj74cQfsEzUqh+sr1RzFqiL1o+PpA==", + ], + + "@emotion/serialize": [ + "@emotion/serialize@1.3.3", + "", + { + "dependencies": { + "@emotion/hash": "^0.9.2", + "@emotion/memoize": "^0.9.0", + "@emotion/unitless": "^0.10.0", + "@emotion/utils": "^1.4.2", + "csstype": "^3.0.2", + }, + }, + "sha512-EISGqt7sSNWHGI76hC7x1CksiXPahbxEOrC5RjmFRJTqLyEK9/9hZvBbiYn70dw4wuwMKiEMCUlR6ZXTSWQqxA==", + ], + + "@emotion/sheet": [ + "@emotion/sheet@1.4.0", + "", + {}, + "sha512-fTBW9/8r2w3dXWYM4HCB1Rdp8NLibOw2+XELH5m5+AkWiL/KqYX6dc0kKYlaYyKjrQ6ds33MCdMPEwgs2z1rqg==", + ], + + "@emotion/unitless": [ + "@emotion/unitless@0.10.0", + "", + {}, + "sha512-dFoMUuQA20zvtVTuxZww6OHoJYgrzfKM1t52mVySDJnMSEa08ruEvdYQbhvyu6soU+NeLVd3yKfTfT0NeV6qGg==", + ], + + "@emotion/use-insertion-effect-with-fallbacks": [ + "@emotion/use-insertion-effect-with-fallbacks@1.2.0", + "", + { "peerDependencies": { "react": ">=16.8.0" } }, + "sha512-yJMtVdH59sxi/aVJBpk9FQq+OR8ll5GT8oWd57UpeaKEVGab41JWaCFA7FRLoMLloOZF/c/wsPoe+bfGmRKgDg==", + ], + + "@emotion/utils": [ + "@emotion/utils@1.4.2", + "", + {}, + "sha512-3vLclRofFziIa3J2wDh9jjbkUz9qk5Vi3IZ/FSTKViB0k+ef0fPV7dYrUIugbgupYDx7v9ud/SjrtEP8Y4xLoA==", + ], + + "@emotion/weak-memoize": [ + "@emotion/weak-memoize@0.4.0", + "", + {}, + "sha512-snKqtPW01tN0ui7yu9rGv69aJXr/a/Ywvl11sUjNtEcRc+ng/mQriFL0wLXMef74iHa/EkftbDzU9F8iFbH+zg==", + ], + + "@floating-ui/core": [ + "@floating-ui/core@1.7.0", + "", + { "dependencies": { "@floating-ui/utils": "^0.2.9" } }, + "sha512-FRdBLykrPPA6P76GGGqlex/e7fbe0F1ykgxHYNXQsH/iTEtjMj/f9bpY5oQqbjt5VgZvgz/uKXbGuROijh3VLA==", + ], + + "@floating-ui/dom": [ + "@floating-ui/dom@1.7.0", + "", + { "dependencies": { "@floating-ui/core": "^1.7.0", "@floating-ui/utils": "^0.2.9" } }, + "sha512-lGTor4VlXcesUMh1cupTUTDoCxMb0V6bm3CnxHzQcw8Eaf1jQbgQX4i02fYgT0vJ82tb5MZ4CZk1LRGkktJCzg==", + ], + + "@floating-ui/react": [ + "@floating-ui/react@0.19.2", + "", + { + "dependencies": { "@floating-ui/react-dom": "^1.3.0", "aria-hidden": "^1.1.3", "tabbable": "^6.0.1" }, + "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" }, + }, + "sha512-JyNk4A0Ezirq8FlXECvRtQOX/iBe5Ize0W/pLkrZjfHW9GUV7Xnq6zm6fyZuQzaHHqEnVizmvlA96e1/CkZv+w==", + ], + + "@floating-ui/react-dom": [ + "@floating-ui/react-dom@1.3.0", + "", + { + "dependencies": { "@floating-ui/dom": "^1.2.1" }, + "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" }, + }, + "sha512-htwHm67Ji5E/pROEAr7f8IKFShuiCKHwUC/UY4vC3I5jiSvGFAYnSYiZO5MlGmads+QqvUkR9ANHEguGrDv72g==", + ], + + "@floating-ui/utils": [ + "@floating-ui/utils@0.2.9", + "", + {}, + "sha512-MDWhGtE+eHw5JW7lq4qhc5yRLS11ERl1c7Z6Xd0a58DozHES6EnNNwUWbMiG4J9Cgj053Bhk8zvlhFYKVhULwg==", + ], + + "@fortawesome/fontawesome-common-types": [ + "@fortawesome/fontawesome-common-types@6.1.1", + "", + {}, + "sha512-wVn5WJPirFTnzN6tR95abCx+ocH+3IFLXAgyavnf9hUmN0CfWoDjPT/BAWsUVwSlYYVBeCLJxaqi7ZGe4uSjBA==", + ], + + "@jridgewell/gen-mapping": [ + "@jridgewell/gen-mapping@0.3.8", + "", + { + "dependencies": { + "@jridgewell/set-array": "^1.2.1", + "@jridgewell/sourcemap-codec": "^1.4.10", + "@jridgewell/trace-mapping": "^0.3.24", + }, + }, + "sha512-imAbBGkb+ebQyxKgzv5Hu2nmROxoDOXHh80evxdoXNOrvAnVx7zimzc1Oo5h9RlfV4vPXaE2iM5pOFbvOCClWA==", + ], + + "@jridgewell/resolve-uri": [ + "@jridgewell/resolve-uri@3.1.2", + "", + {}, + "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + ], + + "@jridgewell/set-array": [ + "@jridgewell/set-array@1.2.1", + "", + {}, + "sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A==", + ], + + "@jridgewell/sourcemap-codec": [ + "@jridgewell/sourcemap-codec@1.5.0", + "", + {}, + "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==", + ], + + "@jridgewell/trace-mapping": [ + "@jridgewell/trace-mapping@0.3.25", + "", + { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==", + ], + + "@mantine/core": [ + "@mantine/core@6.0.22", + "", + { + "dependencies": { + "@floating-ui/react": "^0.19.1", + "@mantine/styles": "6.0.22", + "@mantine/utils": "6.0.22", + "@radix-ui/react-scroll-area": "1.0.2", + "react-remove-scroll": "^2.5.5", + "react-textarea-autosize": "8.3.4", + }, + "peerDependencies": { "@mantine/hooks": "6.0.22", "react": ">=16.8.0", "react-dom": ">=16.8.0" }, + }, + "sha512-6kv0eY7n565fyjgS20qUYeCSxg3f1TJ5vurzbP1HHtFXXKSY0bYoqqDoHipFCt6NxsPQGeiC6cC0c/IWIlxoKQ==", + ], + + "@mantine/hooks": [ + "@mantine/hooks@6.0.22", + "", + { "peerDependencies": { "react": ">=16.8.0" } }, + "sha512-e10//QTN2sAmC4Ryeu5X5L/TsxnrjXMOaGq3dxFPIPsCSwLzyxqySfjzVViWmoPWAj0Ak9MvE2MHFjzmOpA80w==", + ], + + "@mantine/styles": [ + "@mantine/styles@6.0.22", + "", + { + "dependencies": { "clsx": "1.1.1", "csstype": "3.0.9" }, + "peerDependencies": { "@emotion/react": ">=11.9.0", "react": ">=16.8.0", "react-dom": ">=16.8.0" }, + }, + "sha512-Rud/IQp2EFYDiP4csRy2XBrho/Ct+W2/b+XbvCRTeQTmpFy/NfAKm/TWJa5zPvuv/iLTjGkVos9SHw/DteESpQ==", + ], + + "@mantine/utils": [ + "@mantine/utils@6.0.22", + "", + { "peerDependencies": { "react": ">=16.8.0" } }, + "sha512-RSKlNZvxhMCkOFZ6slbYvZYbWjHUM+PxDQnupIOxIdsTZQQjx/BFfrfJ7kQFOP+g7MtpOds8weAetEs5obwMOQ==", + ], + + "@nativewrappers/fivem": [ + "@nativewrappers/fivem@0.0.103", + "", + {}, + "sha512-x0W00Mx9ZN/rTS9XZc5Kf1hjahqRmlo9sPiuJP4kCYeQG4LDJyglXCsHcfNfygGq6WEblG1W2FLgm4MGDn/wHA==", + ], + + "@radix-ui/number": [ + "@radix-ui/number@1.0.0", + "", + { "dependencies": { "@babel/runtime": "^7.13.10" } }, + "sha512-Ofwh/1HX69ZfJRiRBMTy7rgjAzHmwe4kW9C9Y99HTRUcYLUuVT0KESFj15rPjRgKJs20GPq8Bm5aEDJ8DuA3vA==", + ], + + "@radix-ui/primitive": [ + "@radix-ui/primitive@1.0.0", + "", + { "dependencies": { "@babel/runtime": "^7.13.10" } }, + "sha512-3e7rn8FDMin4CgeL7Z/49smCA3rFYY3Ha2rUQ7HRWFadS5iCRw08ZgVT1LaNTCNqgvrUiyczLflrVrF0SRQtNA==", + ], + + "@radix-ui/react-compose-refs": [ + "@radix-ui/react-compose-refs@1.0.0", + "", + { "dependencies": { "@babel/runtime": "^7.13.10" }, "peerDependencies": { "react": "^16.8 || ^17.0 || ^18.0" } }, + "sha512-0KaSv6sx787/hK3eF53iOkiSLwAGlFMx5lotrqD2pTjB18KbybKoEIgkNZTKC60YECDQTKGTRcDBILwZVqVKvA==", + ], + + "@radix-ui/react-context": [ + "@radix-ui/react-context@1.0.0", + "", + { "dependencies": { "@babel/runtime": "^7.13.10" }, "peerDependencies": { "react": "^16.8 || ^17.0 || ^18.0" } }, + "sha512-1pVM9RfOQ+n/N5PJK33kRSKsr1glNxomxONs5c49MliinBY6Yw2Q995qfBUUo0/Mbg05B/sGA0gkgPI7kmSHBg==", + ], + + "@radix-ui/react-direction": [ + "@radix-ui/react-direction@1.0.0", + "", + { "dependencies": { "@babel/runtime": "^7.13.10" }, "peerDependencies": { "react": "^16.8 || ^17.0 || ^18.0" } }, + "sha512-2HV05lGUgYcA6xgLQ4BKPDmtL+QbIZYH5fCOTAOOcJ5O0QbWS3i9lKaurLzliYUDhORI2Qr3pyjhJh44lKA3rQ==", + ], + + "@radix-ui/react-presence": [ + "@radix-ui/react-presence@1.0.0", + "", + { + "dependencies": { + "@babel/runtime": "^7.13.10", + "@radix-ui/react-compose-refs": "1.0.0", + "@radix-ui/react-use-layout-effect": "1.0.0", + }, + "peerDependencies": { "react": "^16.8 || ^17.0 || ^18.0", "react-dom": "^16.8 || ^17.0 || ^18.0" }, + }, + "sha512-A+6XEvN01NfVWiKu38ybawfHsBjWum42MRPnEuqPsBZ4eV7e/7K321B5VgYMPv3Xx5An6o1/l9ZuDBgmcmWK3w==", + ], + + "@radix-ui/react-primitive": [ + "@radix-ui/react-primitive@1.0.1", + "", + { + "dependencies": { "@babel/runtime": "^7.13.10", "@radix-ui/react-slot": "1.0.1" }, + "peerDependencies": { "react": "^16.8 || ^17.0 || ^18.0", "react-dom": "^16.8 || ^17.0 || ^18.0" }, + }, + "sha512-fHbmislWVkZaIdeF6GZxF0A/NH/3BjrGIYj+Ae6eTmTCr7EB0RQAAVEiqsXK6p3/JcRqVSBQoceZroj30Jj3XA==", + ], + + "@radix-ui/react-scroll-area": [ + "@radix-ui/react-scroll-area@1.0.2", + "", + { + "dependencies": { + "@babel/runtime": "^7.13.10", + "@radix-ui/number": "1.0.0", + "@radix-ui/primitive": "1.0.0", + "@radix-ui/react-compose-refs": "1.0.0", + "@radix-ui/react-context": "1.0.0", + "@radix-ui/react-direction": "1.0.0", + "@radix-ui/react-presence": "1.0.0", + "@radix-ui/react-primitive": "1.0.1", + "@radix-ui/react-use-callback-ref": "1.0.0", + "@radix-ui/react-use-layout-effect": "1.0.0", + }, + "peerDependencies": { "react": "^16.8 || ^17.0 || ^18.0", "react-dom": "^16.8 || ^17.0 || ^18.0" }, + }, + "sha512-k8VseTxI26kcKJaX0HPwkvlNBPTs56JRdYzcZ/vzrNUkDlvXBy8sMc7WvCpYzZkHgb+hd72VW9MqkqecGtuNgg==", + ], + + "@radix-ui/react-slot": [ + "@radix-ui/react-slot@1.0.1", + "", + { + "dependencies": { "@babel/runtime": "^7.13.10", "@radix-ui/react-compose-refs": "1.0.0" }, + "peerDependencies": { "react": "^16.8 || ^17.0 || ^18.0" }, + }, + "sha512-avutXAFL1ehGvAXtPquu0YK5oz6ctS474iM3vNGQIkswrVhdrS52e3uoMQBzZhNRAIE0jBnUyXWNmSjGHhCFcw==", + ], + + "@radix-ui/react-use-callback-ref": [ + "@radix-ui/react-use-callback-ref@1.0.0", + "", + { "dependencies": { "@babel/runtime": "^7.13.10" }, "peerDependencies": { "react": "^16.8 || ^17.0 || ^18.0" } }, + "sha512-GZtyzoHz95Rhs6S63D2t/eqvdFCm7I+yHMLVQheKM7nBD8mbZIt+ct1jz4536MDnaOGKIxynJ8eHTkVGVVkoTg==", + ], + + "@radix-ui/react-use-layout-effect": [ + "@radix-ui/react-use-layout-effect@1.0.0", + "", + { "dependencies": { "@babel/runtime": "^7.13.10" }, "peerDependencies": { "react": "^16.8 || ^17.0 || ^18.0" } }, + "sha512-6Tpkq+R6LOlmQb1R5NNETLG0B4YP0wc+klfXafpUCj6JGyaUc8il7/kUZ7m59rGbXGczE9Bs+iz2qloqsZBduQ==", + ], + + "@types/node": [ + "@types/node@16.9.1", + "", + {}, + "sha512-QpLcX9ZSsq3YYUUnD3nFDY8H7wctAhQj/TFKL8Ya8v5fMm3CFXxo8zStsLAl780ltoYoo1WvKUVGBQK+1ifr7g==", + ], + + "@types/parse-json": [ + "@types/parse-json@4.0.2", + "", + {}, + "sha512-dISoDXWWQwUquiKsyZ4Ng+HX2KsPL7LyHKHQwgGFEA3IaKac4Obd+h2a/a6waisAoepJlBcx9paWqjA8/HVjCw==", + ], + + "@types/prop-types": [ + "@types/prop-types@15.7.14", + "", + {}, + "sha512-gNMvNH49DJ7OJYv+KAKn0Xp45p8PLl6zo2YnvDIbTd4J6MER2BmWN49TG7n9LvkyihINxeKW8+3bfS2yDC9dzQ==", + ], + + "@types/react": [ + "@types/react@18.3.20", + "", + { "dependencies": { "@types/prop-types": "*", "csstype": "^3.0.2" } }, + "sha512-IPaCZN7PShZK/3t6Q87pfTkRm6oLTd4vztyoj+cbHUF1g3FfVb2tFIL79uCRKEfv16AhqDMBywP2VW3KIZUvcg==", + ], + + "aria-hidden": [ + "aria-hidden@1.2.4", + "", + { "dependencies": { "tslib": "^2.0.0" } }, + "sha512-y+CcFFwelSXpLZk/7fMB2mUbGtX9lKycf1MWJ7CaTIERyitVlyQx6C+sxcROU2BAJ24OiZyK+8wj2i8AlBoS3A==", + ], + + "babel-plugin-macros": [ + "babel-plugin-macros@3.1.0", + "", + { "dependencies": { "@babel/runtime": "^7.12.5", "cosmiconfig": "^7.0.0", "resolve": "^1.19.0" } }, + "sha512-Cg7TFGpIr01vOQNODXOOaGz2NpCU5gl8x1qJFbb6hbZxR7XrcE2vtbAsTAbJ7/xwJtUuJEw8K8Zr/AE0LHlesg==", + ], + + "callsites": [ + "callsites@3.1.0", + "", + {}, + "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + ], + + "clsx": [ + "clsx@1.1.1", + "", + {}, + "sha512-6/bPho624p3S2pMyvP5kKBPXnI3ufHLObBFCfgx+LkeR5lg2XYy2hqZqUf45ypD8COn2bhgGJSUE+l5dhNBieA==", + ], + + "convert-source-map": [ + "convert-source-map@1.9.0", + "", + {}, + "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==", + ], + + "cosmiconfig": [ + "cosmiconfig@7.1.0", + "", + { + "dependencies": { + "@types/parse-json": "^4.0.0", + "import-fresh": "^3.2.1", + "parse-json": "^5.0.0", + "path-type": "^4.0.0", + "yaml": "^1.10.0", + }, + }, + "sha512-AdmX6xUzdNASswsFtmwSt7Vj8po9IuqXm0UXz7QKPuEUmPB4XyjGfaAr2PSuELMwkRMVH1EpIkX5bTZGRB3eCA==", + ], + + "csstype": [ + "csstype@3.1.3", + "", + {}, + "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", + ], + + "debug": [ + "debug@4.4.0", + "", + { "dependencies": { "ms": "^2.1.3" } }, + "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==", + ], + + "detect-node-es": [ + "detect-node-es@1.1.0", + "", + {}, + "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==", + ], + + "error-ex": [ + "error-ex@1.3.2", + "", + { "dependencies": { "is-arrayish": "^0.2.1" } }, + "sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==", + ], + + "escape-string-regexp": [ + "escape-string-regexp@4.0.0", + "", + {}, + "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + ], + + "fast-printf": [ + "fast-printf@1.6.10", + "", + {}, + "sha512-GwTgG9O4FVIdShhbVF3JxOgSBY2+ePGsu2V/UONgoCPzF9VY6ZdBMKsHKCYQHZwNk3qNouUolRDsgVxcVA5G1w==", + ], + + "find-root": [ + "find-root@1.1.0", + "", + {}, + "sha512-NKfW6bec6GfKc0SGx1e07QZY9PE99u0Bft/0rzSD5k3sO/vwkVUpDUKVm5Gpp5Ue3YfShPFTX2070tDs5kB9Ng==", + ], + + "function-bind": [ + "function-bind@1.1.2", + "", + {}, + "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + ], + + "get-nonce": [ + "get-nonce@1.0.1", + "", + {}, + "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==", + ], + + "globals": [ + "globals@11.12.0", + "", + {}, + "sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==", + ], + + "hasown": [ + "hasown@2.0.2", + "", + { "dependencies": { "function-bind": "^1.1.2" } }, + "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + ], + + "hoist-non-react-statics": [ + "hoist-non-react-statics@3.3.2", + "", + { "dependencies": { "react-is": "^16.7.0" } }, + "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==", + ], + + "import-fresh": [ + "import-fresh@3.3.1", + "", + { "dependencies": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" } }, + "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + ], + + "is-arrayish": [ + "is-arrayish@0.2.1", + "", + {}, + "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", + ], + + "is-core-module": [ + "is-core-module@2.16.1", + "", + { "dependencies": { "hasown": "^2.0.2" } }, + "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", + ], + + "js-tokens": [ + "js-tokens@4.0.0", + "", + {}, + "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + ], + + "jsesc": [ + "jsesc@3.1.0", + "", + { "bin": { "jsesc": "bin/jsesc" } }, + "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + ], + + "json-parse-even-better-errors": [ + "json-parse-even-better-errors@2.3.1", + "", + {}, + "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", + ], + + "lines-and-columns": [ + "lines-and-columns@1.2.4", + "", + {}, + "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + ], + + "ms": [ + "ms@2.1.3", + "", + {}, + "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + ], + + "parent-module": [ + "parent-module@1.0.1", + "", + { "dependencies": { "callsites": "^3.0.0" } }, + "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + ], + + "parse-json": [ + "parse-json@5.2.0", + "", + { + "dependencies": { + "@babel/code-frame": "^7.0.0", + "error-ex": "^1.3.1", + "json-parse-even-better-errors": "^2.3.0", + "lines-and-columns": "^1.1.6", + }, + }, + "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", + ], + + "path-parse": [ + "path-parse@1.0.7", + "", + {}, + "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + ], + + "path-type": [ + "path-type@4.0.0", + "", + {}, + "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==", + ], + + "picocolors": [ + "picocolors@1.1.1", + "", + {}, + "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + ], + + "prettier": [ + "prettier@2.8.8", + "", + { "bin": { "prettier": "bin-prettier.js" } }, + "sha512-tdN8qQGvNjw4CHbY+XXk0JgCXn9QiF21a55rBe5LJAU+kDyC4WQn4+awm2Xfk2lQMk5fKup9XgzTZtGkjBdP9Q==", + ], + + "react": [ + "react@19.1.0", + "", + {}, + "sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg==", + ], + + "react-dom": [ + "react-dom@19.1.0", + "", + { "dependencies": { "scheduler": "^0.26.0" }, "peerDependencies": { "react": "^19.1.0" } }, + "sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g==", + ], + + "react-is": [ + "react-is@16.13.1", + "", + {}, + "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + ], + + "react-remove-scroll": [ + "react-remove-scroll@2.6.3", + "", + { + "dependencies": { + "react-remove-scroll-bar": "^2.3.7", + "react-style-singleton": "^2.2.3", + "tslib": "^2.1.0", + "use-callback-ref": "^1.3.3", + "use-sidecar": "^1.1.3", + }, + "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, + "optionalPeers": ["@types/react"], + }, + "sha512-pnAi91oOk8g8ABQKGF5/M9qxmmOPxaAnopyTHYfqYEwJhyFrbbBtHuSgtKEoH0jpcxx5o3hXqH1mNd9/Oi+8iQ==", + ], + + "react-remove-scroll-bar": [ + "react-remove-scroll-bar@2.3.8", + "", + { + "dependencies": { "react-style-singleton": "^2.2.2", "tslib": "^2.0.0" }, + "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" }, + "optionalPeers": ["@types/react"], + }, + "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==", + ], + + "react-style-singleton": [ + "react-style-singleton@2.2.3", + "", + { + "dependencies": { "get-nonce": "^1.0.0", "tslib": "^2.0.0" }, + "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, + "optionalPeers": ["@types/react"], + }, + "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==", + ], + + "react-textarea-autosize": [ + "react-textarea-autosize@8.3.4", + "", + { + "dependencies": { "@babel/runtime": "^7.10.2", "use-composed-ref": "^1.3.0", "use-latest": "^1.2.1" }, + "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0" }, + }, + "sha512-CdtmP8Dc19xL8/R6sWvtknD/eCXkQr30dtvC4VmGInhRsfF8X/ihXCq6+9l9qbxmKRiq407/7z5fxE7cVWQNgQ==", + ], + + "resolve": [ + "resolve@1.22.10", + "", + { + "dependencies": { + "is-core-module": "^2.16.0", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0", + }, + "bin": { "resolve": "bin/resolve" }, + }, + "sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w==", + ], + + "resolve-from": [ + "resolve-from@4.0.0", + "", + {}, + "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + ], + + "scheduler": [ + "scheduler@0.26.0", + "", + {}, + "sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA==", + ], + + "source-map": [ + "source-map@0.5.7", + "", + {}, + "sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ==", + ], + + "stylis": [ + "stylis@4.2.0", + "", + {}, + "sha512-Orov6g6BB1sDfYgzWfTHDOxamtX1bE/zo104Dh9e6fqJ3PooipYyfJ0pUmrZO2wAvO8YbEyeFrkV91XTsGMSrw==", + ], + + "supports-preserve-symlinks-flag": [ + "supports-preserve-symlinks-flag@1.0.0", + "", + {}, + "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + ], + + "tabbable": [ + "tabbable@6.2.0", + "", + {}, + "sha512-Cat63mxsVJlzYvN51JmVXIgNoUokrIaT2zLclCXjRd8boZ0004U4KCs/sToJ75C6sdlByWxpYnb5Boif1VSFew==", + ], + + "tslib": [ + "tslib@2.8.1", + "", + {}, + "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + ], + + "typescript": [ + "typescript@5.8.3", + "", + { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, + "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", + ], + + "use-callback-ref": [ + "use-callback-ref@1.3.3", + "", + { + "dependencies": { "tslib": "^2.0.0" }, + "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, + "optionalPeers": ["@types/react"], + }, + "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==", + ], + + "use-composed-ref": [ + "use-composed-ref@1.4.0", + "", + { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, + "sha512-djviaxuOOh7wkj0paeO1Q/4wMZ8Zrnag5H6yBvzN7AKKe8beOaED9SF5/ByLqsku8NP4zQqsvM2u3ew/tJK8/w==", + ], + + "use-isomorphic-layout-effect": [ + "use-isomorphic-layout-effect@1.2.0", + "", + { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, + "sha512-q6ayo8DWoPZT0VdG4u3D3uxcgONP3Mevx2i2b0434cwWBoL+aelL1DzkXI6w3PhTZzUeR2kaVlZn70iCiseP6w==", + ], + + "use-latest": [ + "use-latest@1.3.0", + "", + { + "dependencies": { "use-isomorphic-layout-effect": "^1.1.1" }, + "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" }, + }, + "sha512-mhg3xdm9NaM8q+gLT8KryJPnRFOz1/5XPBhmDEVZK1webPzDjrPk7f/mbpeLqTgB9msytYWANxgALOCJKnLvcQ==", + ], + + "use-sidecar": [ + "use-sidecar@1.1.3", + "", + { + "dependencies": { "detect-node-es": "^1.1.0", "tslib": "^2.0.0" }, + "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, + "optionalPeers": ["@types/react"], + }, + "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==", + ], + + "yaml": [ + "yaml@1.10.2", + "", + {}, + "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==", + ], + + "@mantine/styles/csstype": [ + "csstype@3.0.9", + "", + {}, + "sha512-rpw6JPxK6Rfg1zLOYCSwle2GFOOsnjmDYDaBwEcwoOg4qlsIVCN789VkBZDJAGi4T07gI4YSutR43t9Zz4Lzuw==", + ], + }, } diff --git a/package/package.json b/package/package.json index a652a1cfe..1a1bad83f 100644 --- a/package/package.json +++ b/package/package.json @@ -1,5 +1,5 @@ { - "name": "@communityox/ox_lib", + "name": "@overextended/ox_lib", "author": "Overextended", "version": "3.32.3", "description": "JS/TS wrapper for ox_lib exports", @@ -17,14 +17,14 @@ "ox_lib", "ox", "overextended", - "communityox" + "overextended" ], "repository": { "type": "git", - "url": "git+https://github.com/CommunityOx/ox_lib.git" + "url": "git+https://github.com/overextended/ox_lib.git" }, "bugs": { - "url": "https://github.com/CommunityOx/ox_lib/issues" + "url": "https://github.com/overextended/ox_lib/issues" }, "license": "LGPL-3.0", "dependencies": { @@ -42,4 +42,4 @@ "@types/react": "^18.2.66", "prettier": "^2.8.8" } -} \ No newline at end of file +} diff --git a/resource/init.lua b/resource/init.lua index fab7b2f6e..0e21a1be3 100644 --- a/resource/init.lua +++ b/resource/init.lua @@ -44,7 +44,7 @@ cache = { if not LoadResourceFile(lib.name, 'web/build/index.html') then local err = - '^1Unable to load UI. Build ox_lib or download the latest release.\n ^3https://github.com/communityox/ox_lib/releases/latest/download/ox_lib.zip^0' + '^1Unable to load UI. Build ox_lib or download the latest release.\n ^3https://github.com/overextended/ox_lib/releases/latest/download/ox_lib.zip^0' function lib.hasLoaded() return err end error(err) diff --git a/resource/version/server.lua b/resource/version/server.lua index 74839aa19..04edbd918 100644 --- a/resource/version/server.lua +++ b/resource/version/server.lua @@ -7,25 +7,25 @@ ]] function lib.versionCheck(repository) - local resource = GetInvokingResource() or GetCurrentResourceName() + local resource = GetInvokingResource() or GetCurrentResourceName() - local currentVersion = GetResourceMetadata(resource, 'version', 0) + local currentVersion = GetResourceMetadata(resource, 'version', 0) - if currentVersion then - currentVersion = currentVersion:match('%d+%.%d+%.%d+') - end + if currentVersion then + currentVersion = currentVersion:match('%d+%.%d+%.%d+') + end - if not currentVersion then return print(("^1Unable to determine current resource version for '%s' ^0"):format(resource)) end + if not currentVersion then return print(("^1Unable to determine current resource version for '%s' ^0"):format(resource)) end - SetTimeout(1000, function() - PerformHttpRequest(('https://api.github.com/repos/%s/releases/latest'):format(repository), function(status, response) - if status ~= 200 then return end + SetTimeout(1000, function() + PerformHttpRequest(('https://api.github.com/repos/%s/releases/latest'):format(repository), function(status, response) + if status ~= 200 then return end - response = json.decode(response) - if response.prerelease then return end + response = json.decode(response) + if response.prerelease then return end - local latestVersion = response.tag_name:match('%d+%.%d+%.%d+') - if not latestVersion or latestVersion == currentVersion then return end + local latestVersion = response.tag_name:match('%d+%.%d+%.%d+') + if not latestVersion or latestVersion == currentVersion then return end local cv = { string.strsplit('.', currentVersion) } local lv = { string.strsplit('.', latestVersion) } @@ -36,11 +36,13 @@ function lib.versionCheck(repository) if current ~= minimum then if current < minimum then return print(('^3An update is available for %s (current version: %s)\r\n%s^0'):format(resource, currentVersion, response.html_url)) - else break end + else + break + end end end - end, 'GET') - end) + end, 'GET') + end) end -lib.versionCheck('communityox/ox_lib') +lib.versionCheck('overextended/ox_lib') From 2decd8c058e159441a18960449131bd0d6862a76 Mon Sep 17 00:00:00 2001 From: Linden <65407488+thelindat@users.noreply.github.com> Date: Fri, 24 Apr 2026 17:13:28 +1000 Subject: [PATCH 2/8] Update release.yml --- .github/workflows/release.yml | 15 +++++---------- 1 file changed, 5 insertions(+), 10 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index fed54b2ab..8f4422a35 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -3,7 +3,7 @@ name: Create release on: push: tags: - - 'v*.*.*' + - "v*.*.*" permissions: id-token: write # Required for OIDC @@ -11,7 +11,7 @@ permissions: jobs: create-release: - if: github.actor_id != 210085057 + if: github.actor_id != 278903378 runs-on: ubuntu-latest steps: - name: Install zip @@ -22,7 +22,6 @@ jobs: with: bun-version: latest - - name: Generate GitHub App token id: app_token uses: tibdex/github-app-token@v2 @@ -37,7 +36,6 @@ jobs: ref: ${{ github.event.repository.default_branch }} token: ${{ steps.app_token.outputs.token }} - - name: Bump package version run: bun run .github/actions/bump-package-version.js env: @@ -54,8 +52,7 @@ jobs: add: '["fxmanifest.lua", "package/package.json"]' push: true default_author: github_actions - message: 'chore: bump version to ${{ github.ref_name }}' - + message: "chore: bump version to ${{ github.ref_name }}" - name: Install package dependencies run: bun install --frozen-lockfile @@ -69,7 +66,6 @@ jobs: run: bun run build working-directory: web - - name: Bundle files run: | mkdir -p ./temp/ox_lib/web @@ -79,7 +75,7 @@ jobs: cd ./temp && zip -r ../ox_lib.zip ./ox_lib - name: Create release - uses: 'marvinpinto/action-automatic-releases@v1.2.1' + uses: "marvinpinto/action-automatic-releases@v1.2.1" with: repo_token: ${{ secrets.GITHUB_TOKEN }} prerelease: false @@ -90,11 +86,10 @@ jobs: with: ref: ${{ github.ref_name }} - - name: Setup Node.js uses: actions/setup-node@v4 with: - node-version: '24' + node-version: "24" - name: Publish package to npm registry run: npm publish --access public From 307960063ae0d14b0c2445bd08f31a8fc4c24614 Mon Sep 17 00:00:00 2001 From: github-actions <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 24 Apr 2026 07:17:19 +0000 Subject: [PATCH 3/8] chore: bump version to v3.32.3 --- package/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package/package.json b/package/package.json index 1a1bad83f..a2dcde298 100644 --- a/package/package.json +++ b/package/package.json @@ -42,4 +42,4 @@ "@types/react": "^18.2.66", "prettier": "^2.8.8" } -} +} \ No newline at end of file From aa080e8c49fc8602d5f0e780b5369da3aa48182f Mon Sep 17 00:00:00 2001 From: github-actions <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 24 Apr 2026 07:40:19 +0000 Subject: [PATCH 4/8] chore: bump version to v3.32.5 --- fxmanifest.lua | 2 +- package/package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/fxmanifest.lua b/fxmanifest.lua index f5cb36fd9..76df3dadf 100644 --- a/fxmanifest.lua +++ b/fxmanifest.lua @@ -6,7 +6,7 @@ rdr3_warning 'I acknowledge that this is a prerelease build of RedM, and I am aw name 'ox_lib' author 'Overextended' -version '3.32.3' +version '3.32.5' license 'LGPL-3.0-or-later' repository 'https://github.com/overextended/ox_lib' description 'A library of shared functions to utilise in other resources.' diff --git a/package/package.json b/package/package.json index a2dcde298..4f3debeba 100644 --- a/package/package.json +++ b/package/package.json @@ -1,7 +1,7 @@ { "name": "@overextended/ox_lib", "author": "Overextended", - "version": "3.32.3", + "version": "3.32.5", "description": "JS/TS wrapper for ox_lib exports", "main": "./shared/index.js", "types": "./shared/index.d.ts", From 508da30a7d37435da8b3042bde7a830b9a55cfc5 Mon Sep 17 00:00:00 2001 From: Linden <65407488+thelindat@users.noreply.github.com> Date: Sat, 25 Apr 2026 08:44:49 +1000 Subject: [PATCH 5/8] Update release.yml --- .github/workflows/release.yml | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8f4422a35..6ae7ad53d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -36,6 +36,11 @@ jobs: ref: ${{ github.event.repository.default_branch }} token: ${{ steps.app_token.outputs.token }} + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: "24" + - name: Bump package version run: bun run .github/actions/bump-package-version.js env: @@ -74,6 +79,10 @@ jobs: cp -r ./web/build ./temp/ox_lib/web/ cd ./temp && zip -r ../ox_lib.zip ./ox_lib + - name: Publish package to npm registry + run: npm publish --access public + working-directory: package + - name: Create release uses: "marvinpinto/action-automatic-releases@v1.2.1" with: @@ -85,12 +94,3 @@ jobs: uses: EndBug/latest-tag@v1 with: ref: ${{ github.ref_name }} - - - name: Setup Node.js - uses: actions/setup-node@v4 - with: - node-version: "24" - - - name: Publish package to npm registry - run: npm publish --access public - working-directory: package From eb314c93ea3712a1c5d1dfcaab9d883d332b7af9 Mon Sep 17 00:00:00 2001 From: github-actions <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 24 Apr 2026 22:49:01 +0000 Subject: [PATCH 6/8] chore: bump version to v3.32.6 --- fxmanifest.lua | 2 +- package/package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/fxmanifest.lua b/fxmanifest.lua index 76df3dadf..4fc3efda4 100644 --- a/fxmanifest.lua +++ b/fxmanifest.lua @@ -6,7 +6,7 @@ rdr3_warning 'I acknowledge that this is a prerelease build of RedM, and I am aw name 'ox_lib' author 'Overextended' -version '3.32.5' +version '3.32.6' license 'LGPL-3.0-or-later' repository 'https://github.com/overextended/ox_lib' description 'A library of shared functions to utilise in other resources.' diff --git a/package/package.json b/package/package.json index 4f3debeba..6d6790de0 100644 --- a/package/package.json +++ b/package/package.json @@ -1,7 +1,7 @@ { "name": "@overextended/ox_lib", "author": "Overextended", - "version": "3.32.5", + "version": "3.32.6", "description": "JS/TS wrapper for ox_lib exports", "main": "./shared/index.js", "types": "./shared/index.d.ts", From 614ef5681b42efe6ef52017b23af38c1634f3463 Mon Sep 17 00:00:00 2001 From: Linden <65407488+thelindat@users.noreply.github.com> Date: Tue, 28 Apr 2026 13:39:46 +1000 Subject: [PATCH 7/8] fix(zones): handle concave polygons and improve debug (#755) Signed-off-by: Linden <65407488+thelindat@users.noreply.github.com> --- imports/zones/shared.lua | 165 ++++++++++++++++++++++++++------------- 1 file changed, 109 insertions(+), 56 deletions(-) diff --git a/imports/zones/shared.lua b/imports/zones/shared.lua index d606bff6f..0c33fa479 100644 --- a/imports/zones/shared.lua +++ b/imports/zones/shared.lua @@ -27,32 +27,68 @@ local glm = require 'glm' local Zones = {} _ENV.Zones = Zones -local function nextFreePoint(points, b, len) - for i = 1, len do - local n = (i + b) % len - - n = n ~= 0 and n or len +local function unableToSplit(polygon) + print('The following polygon has failed to be split into triangles for debugging and may be malformed.') - if points[n] then - return n - end + for k, v in pairs(polygon) do + print(k, v) end end -local function unableToSplit(polygon) - print('The following polygon is malformed and has failed to be split into triangles for debug') +local function isCCW(a, b, c) + return ((b.x - a.x) * (c.y - a.y) - (b.y - a.y) * (c.x - a.x)) > 0 +end - for k, v in pairs(polygon) do - print(k, v) +local function signedArea(vertices) + local area = 0 + + for i = 1, #vertices do + local j = (i % #vertices) + 1 + area = area + (vertices[i].x * vertices[j].y - vertices[j].x * vertices[i].y) end + + return area * 0.5 end -local function getTriangles(polygon) +local function getTriangles(polygon, height, type) + if type == 'sphere' then return end + local triangles = {} + local _ = defer(function() + local thickness = vec(0, 0, height / 2) + + for i = 1, #triangles do + local triangle = triangles[i] + local copy = table.clone(triangle) + + for j = 1, 3 do + copy[j] = triangle[j] - thickness + triangle[j] = triangle[j] + thickness + end + + table.insert(triangles, copy) + end + end) + + if type == 'box' then + table.move({ + { polygon[1], polygon[2], polygon[3] }, { polygon[1], polygon[3], polygon[4] } + }, 1, 2, 1, triangles) + return triangles + end + + local numPoints = #polygon + + if numPoints < 3 then + unableToSplit(polygon) + + return triangles + end + if polygon:isConvex() then - for i = 2, #polygon - 1 do - triangles[#triangles + 1] = mat(polygon[1], polygon[i], polygon[i + 1]) + for i = 2, numPoints - 1 do + triangles[#triangles + 1] = { polygon[1], polygon[i], polygon[i + 1] } end return triangles @@ -64,42 +100,53 @@ local function getTriangles(polygon) return triangles end - local points = {} - local polygonN = #polygon + local indices = table.create(numPoints, 0) + local reverse = signedArea(polygon) < 0 + + print(signedArea(polygon), isCCW(polygon[1], polygon[2], polygon[3])) - for i = 1, polygonN do - points[i] = polygon[i] + for i = 1, numPoints do + indices[i] = reverse and numPoints + 1 - i or i end - local a, b, c = 1, 2, 3 - local zValue = polygon[1].z - local count = 0 + while #indices > 2 do + local foundEar = false - while polygonN - #triangles > 2 do - local a2d = polygon[a].xy - local c2d = polygon[c].xy + for i = 1, #indices do + local i1 = indices[(i - 2) % #indices + 1] + local i2 = indices[(i - 1) % #indices + 1] + local i3 = indices[i % #indices + 1] + local a, b, c = polygon[i1], polygon[i2], polygon[i3] - if polygon:containsSegment(vec3(glm.segment2d.getPoint(a2d, c2d, 0.01), zValue), vec3(glm.segment2d.getPoint(a2d, c2d, 0.99), zValue)) then - triangles[#triangles + 1] = mat(polygon[a], polygon[b], polygon[c]) - points[b] = false + if isCCW(a, b, c) then + local isEar = true + local triangle = glm.polygon.new({ a, b, c }) - b = c - c = nextFreePoint(points, b, polygonN) - else - a = b - b = c - c = nextFreePoint(points, b, polygonN) - end + for j = 1, #indices do + local idx = indices[j] - count += 1 + if idx ~= i1 and idx ~= i2 and idx ~= i3 then + if triangle:contains(polygon[idx]) then + isEar = false + break + end + end + end - if count > polygonN and #triangles == 0 then - unableToSplit(polygon) + if isEar then + table.insert(triangles, { a, b, c }) + table.remove(indices, (i - 1) % #indices + 1) - return triangles + foundEar = true + break + end + end end - Wait(0) + if not foundEar then + unableToSplit(polygon) + break + end end return triangles @@ -253,31 +300,38 @@ local DrawLine = DrawLine local DrawPoly = DrawPoly local function debugPoly(self) + local rgba = self.debugColour + local thickness = vec(0, 0, self.thickness / 2) + for i = 1, #self.triangles do - local triangle = self.triangles[i] - DrawPoly(triangle[1].x, triangle[1].y, triangle[1].z, triangle[2].x, triangle[2].y, triangle[2].z, triangle[3].x, triangle[3].y, triangle[3].z, - self.debugColour.r, self.debugColour.g, self.debugColour.b, self.debugColour.a) - DrawPoly(triangle[2].x, triangle[2].y, triangle[2].z, triangle[1].x, triangle[1].y, triangle[1].z, triangle[3].x, triangle[3].y, triangle[3].z, - self.debugColour.r, self.debugColour.g, self.debugColour.b, self.debugColour.a) + local a = self.triangles[i][1] + local b = self.triangles[i][2] + local c = self.triangles[i][3] + + DrawPoly(a.x, a.y, a.z, b.x, b.y, b.z, c.x, c.y, c.z, rgba.r, rgba.g, rgba.b, rgba.a) + DrawPoly(b.x, b.y, b.z, a.x, a.y, a.z, c.x, c.y, c.z, rgba.r, rgba.g, rgba.b, rgba.a) end + -- do return end for i = 1, #self.polygon do - local thickness = vec(0, 0, self.thickness / 2) local a = self.polygon[i] + thickness local b = self.polygon[i] - thickness local c = (self.polygon[i + 1] or self.polygon[1]) + thickness local d = (self.polygon[i + 1] or self.polygon[1]) - thickness - DrawLine(a.x, a.y, a.z, b.x, b.y, b.z, self.debugColour.r, self.debugColour.g, self.debugColour.b, 225) - DrawLine(a.x, a.y, a.z, c.x, c.y, c.z, self.debugColour.r, self.debugColour.g, self.debugColour.b, 225) - DrawLine(b.x, b.y, b.z, d.x, d.y, d.z, self.debugColour.r, self.debugColour.g, self.debugColour.b, 225) - DrawPoly(a.x, a.y, a.z, b.x, b.y, b.z, c.x, c.y, c.z, self.debugColour.r, self.debugColour.g, self.debugColour.b, self.debugColour.a) - DrawPoly(c.x, c.y, c.z, b.x, b.y, b.z, a.x, a.y, a.z, self.debugColour.r, self.debugColour.g, self.debugColour.b, self.debugColour.a) - DrawPoly(b.x, b.y, b.z, c.x, c.y, c.z, d.x, d.y, d.z, self.debugColour.r, self.debugColour.g, self.debugColour.b, self.debugColour.a) - DrawPoly(d.x, d.y, d.z, c.x, c.y, c.z, b.x, b.y, b.z, self.debugColour.r, self.debugColour.g, self.debugColour.b, self.debugColour.a) + + DrawLine(a.x, a.y, a.z, b.x, b.y, b.z, rgba.r, rgba.g, rgba.b, 225) + DrawLine(a.x, a.y, a.z, c.x, c.y, c.z, rgba.r, rgba.g, rgba.b, 225) + DrawLine(b.x, b.y, b.z, d.x, d.y, d.z, rgba.r, rgba.g, rgba.b, 225) + + DrawPoly(a.x, a.y, a.z, b.x, b.y, b.z, c.x, c.y, c.z, rgba.r, rgba.g, rgba.b, rgba.a) + DrawPoly(c.x, c.y, c.z, b.x, b.y, b.z, a.x, a.y, a.z, rgba.r, rgba.g, rgba.b, rgba.a) + DrawPoly(b.x, b.y, b.z, c.x, c.y, c.z, d.x, d.y, d.z, rgba.r, rgba.g, rgba.b, rgba.a) + DrawPoly(d.x, d.y, d.z, c.x, c.y, c.z, b.x, b.y, b.z, rgba.r, rgba.g, rgba.b, rgba.a) end end local function debugSphere(self) - DrawMarker(28, self.coords.x, self.coords.y, self.coords.z, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, self.radius, self.radius, self.radius, self.debugColour.r, + DrawMarker(28, self.coords.x, self.coords.y, self.coords.z, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, self.radius, self.radius, + self.radius, self.debugColour.r, ---@diagnostic disable-next-line: param-type-mismatch self.debugColour.g, self.debugColour.b, self.debugColour.a, false, false, 0, false, false, false, false) end @@ -332,8 +386,7 @@ local function setDebug(self, bool, colour) if bool and self.debug and self.debug ~= true then return end - self.triangles = self.__type == 'poly' and getTriangles(self.polygon) or - self.__type == 'box' and { mat(self.polygon[1], self.polygon[2], self.polygon[3]), mat(self.polygon[1], self.polygon[3], self.polygon[4]) } or nil + self.triangles = getTriangles(self.polygon, self.thickness, self.__type) self.debug = self.__type == 'sphere' and debugSphere or debugPoly or nil end From e74a524c86dac6b1d92e7b34deba50bc8605cfe4 Mon Sep 17 00:00:00 2001 From: Kenshin13 <63159154+Kenshiin13@users.noreply.github.com> Date: Sun, 26 Apr 2026 15:18:35 +0200 Subject: [PATCH 8/8] feat(test): add lib.test unit-testing framework --- fxmanifest.lua | 1 + imports/getFilesInDirectory/server.lua | 74 +++- imports/test/README.md | 391 ++++++++++++++++++ imports/test/discover.lua | 78 ++++ imports/test/dsl.lua | 70 ++++ imports/test/examples/_demo_failures.lua | 33 ++ imports/test/examples/_register_helper.lua | 11 + imports/test/examples/assertion_errors.lua | 74 ++++ imports/test/examples/async.lua | 64 +++ imports/test/examples/hooks.lua | 85 ++++ imports/test/examples/matchers.lua | 134 +++++++ imports/test/examples/mocks.lua | 64 +++ imports/test/examples/parameterized.lua | 28 ++ imports/test/examples/passing.lua | 44 +++ imports/test/examples/run_options.lua | 188 +++++++++ imports/test/examples/spies.lua | 57 +++ imports/test/examples/timeouts.lua | 60 +++ imports/test/expect.lua | 234 +++++++++++ imports/test/helpers.lua | 104 +++++ imports/test/mock.lua | 92 +++++ imports/test/registry.lua | 102 +++++ imports/test/reporter.lua | 141 +++++++ imports/test/runner.lua | 436 +++++++++++++++++++++ imports/test/server.lua | 147 +++++++ init.lua | 6 + 25 files changed, 2698 insertions(+), 20 deletions(-) create mode 100644 imports/test/README.md create mode 100644 imports/test/discover.lua create mode 100644 imports/test/dsl.lua create mode 100644 imports/test/examples/_demo_failures.lua create mode 100644 imports/test/examples/_register_helper.lua create mode 100644 imports/test/examples/assertion_errors.lua create mode 100644 imports/test/examples/async.lua create mode 100644 imports/test/examples/hooks.lua create mode 100644 imports/test/examples/matchers.lua create mode 100644 imports/test/examples/mocks.lua create mode 100644 imports/test/examples/parameterized.lua create mode 100644 imports/test/examples/passing.lua create mode 100644 imports/test/examples/run_options.lua create mode 100644 imports/test/examples/spies.lua create mode 100644 imports/test/examples/timeouts.lua create mode 100644 imports/test/expect.lua create mode 100644 imports/test/helpers.lua create mode 100644 imports/test/mock.lua create mode 100644 imports/test/registry.lua create mode 100644 imports/test/reporter.lua create mode 100644 imports/test/runner.lua create mode 100644 imports/test/server.lua diff --git a/fxmanifest.lua b/fxmanifest.lua index 4fc3efda4..2250d6710 100644 --- a/fxmanifest.lua +++ b/fxmanifest.lua @@ -43,6 +43,7 @@ client_scripts { server_scripts { 'imports/callback/server.lua', 'imports/getFilesInDirectory/server.lua', + 'imports/test/server.lua', 'resource/**/server.lua', 'resource/**/server/*.lua', } diff --git a/imports/getFilesInDirectory/server.lua b/imports/getFilesInDirectory/server.lua index 5862bb10a..66ab2d479 100644 --- a/imports/getFilesInDirectory/server.lua +++ b/imports/getFilesInDirectory/server.lua @@ -6,11 +6,15 @@ Copyright © 2025 Linden ]] ----@param path string ----@param pattern string ----@return table string[] +---List files in a directory inside a resource. Returns paths relative to +---`path` so each entry can be appended back to it for further use. +--- +---@param path string resource-relative path, optionally `@resource/subdir` for cross-resource +---@param pattern string Lua pattern matched against each returned path +---@param recursive? boolean walk subdirectories (default false) +---@return string[] files ---@return integer fileCount -function lib.getFilesInDirectory(path, pattern) +function lib.getFilesInDirectory(path, pattern, recursive) local resource = cache.resource if path:find('^@') then @@ -18,28 +22,58 @@ function lib.getFilesInDirectory(path, pattern) path = path:sub(#resource + 3) end + -- os.getenv('OS') isn't reliable across all FXServer environments. The + -- resource path itself tells us: drive letter or backslash means Windows. + local rawPath = GetResourcePath(resource) + local windows = rawPath:find('\\', 1, true) ~= nil or rawPath:match('^%a:') ~= nil + local resourcePath = rawPath:gsub('\\', '/'):gsub('//', '/') + local relRoot = path:gsub('\\', '/'):gsub('/$', ''):gsub('^/', '') + local fullDir = ('%s/%s'):format(resourcePath, relRoot):gsub('//', '/'):gsub('/$', '') + + local cmd + if recursive then + if windows then + cmd = ('dir "%s" /b /s /a-d 2>&1'):format(fullDir:gsub('/', '\\')) + else + cmd = ('find "%s" -type f 2>&1'):format(fullDir) + end + else + if windows then + cmd = ('dir "%s" /b 2>&1'):format(fullDir:gsub('/', '\\')) + else + cmd = ('ls "%s" 2>&1'):format(fullDir) + end + end + + local pipe = io.popen(cmd) + if not pipe then return {}, 0 end + local files = {} local fileCount = 0 - local windows = string.match(os.getenv('OS') or '', 'Windows') - local command = ('%s%s%s'):format( - windows and 'dir "' or 'ls "', - (GetResourcePath(resource):gsub('//', '/') .. '/' .. path):gsub('\\', '/'), - windows and '/" /b' or '/"' - ) - - local dir = io.popen(command) - - if dir then - for line in dir:lines() do - if line:match(pattern) then - fileCount += 1 - files[fileCount] = line + local prefix = fullDir .. '/' + + for line in pipe:lines() do + -- Windows io.popen leaves trailing \r, which breaks end-of-string patterns. + line = line:gsub('[\r\n]+$', '') + if line ~= '' and line ~= '.' and line ~= '..' then + local normalized = line:gsub('\\', '/') + -- Some shells return full paths (Windows `dir /s`, unix `find`), + -- others return bare filenames. Slash presence tells us which. + local rel + if normalized:find('/', 1, true) then + rel = normalized:sub(#prefix + 1) + if rel:sub(1, 1) == '/' then rel = rel:sub(2) end + else + rel = normalized + end + if rel ~= '' and rel:match(pattern) then + fileCount = fileCount + 1 + files[fileCount] = rel end end - - dir:close() end + pipe:close() return files, fileCount end diff --git a/imports/test/README.md b/imports/test/README.md new file mode 100644 index 000000000..ba9dab2f1 --- /dev/null +++ b/imports/test/README.md @@ -0,0 +1,391 @@ +# lib.test + +Unit-test framework for FiveM resources. Server-side, runs inside the FiveM runtime, modeled on Vitest. + +```lua +lib.test.describe('math', function() + lib.test.it('adds', function() + lib.test.expect(1 + 1):toBe(2) + end) +end) +``` + +## Setup + +In your `fxmanifest.lua`: + +```lua +dependency 'ox_lib' + +ox_test_dir 'tests' + +server_scripts { + '@ox_lib/init.lua', + 'server/your_code.lua', +} +``` + +Drop test files anywhere under `tests/`, named `*.test.lua`. They auto-load when discovery runs. + +## Run + +From the server console: + +``` +oxtest # run ox_lib's bundled examples +oxtest my_resource # run tests in my_resource +oxtest my_resource auth # filter by substring +``` + +For JSON output (CI): + +``` +set ox:test:reporter "json" +oxtest my_resource +``` + +Or programmatically: + +```lua +local result = exports['my_resource']:runOxTests('auth', 'console') +print(result.passed, result.failed) +``` + +## Writing tests + +### `describe` / `it` + +```lua +lib.test.describe('Inventory', function() + lib.test.it('starts empty', function() + local inv = Inventory:new() + lib.test.expect(inv:count('bread')):toBe(0) + end) +end) +``` + +### `it.skip`, `it.only`, `it.each` + +```lua +lib.test.it.skip('not ready', function() end) + +lib.test.it.only('focused', function() end) -- skips everything else + +lib.test.it.each({ {1,1,2}, {2,3,5} })('adds %s + %s = %s', function(case) + lib.test.expect(case[1] + case[2]):toBe(case[3]) +end) +``` + +### Hooks + +```lua +lib.test.describe('db', function() + local db + lib.test.beforeAll(function() db = Database.connect() end) + lib.test.afterAll(function() db:close() end) + lib.test.beforeEach(function() db:reset() end) + lib.test.afterEach(function() end) +end) +``` + +Outer `beforeEach` runs before inner. `afterEach` reverses. + +## Matchers + +Every matcher chains with `.never` to invert. + +| Matcher | Passes when | +| ----------------------------------------------- | ------------------------------------------------------------- | +| `:toBe(x)` | `actual == x` (reference equality) | +| `:toEqual(x)` | deep equal, handles cycles | +| `:toBeTruthy()` / `:toBeFalsy()` / `:toBeNil()` | obvious | +| `:toBeGreaterThan(n)` / `:toBeLessThan(n)` | numeric | +| `:toBeCloseTo(n, decimals?)` | within `0.5 * 10^-decimals`, default 2 | +| `:toBeCallable()` | function, or table with `__call` (FiveM function refs, mocks) | +| `:toContain(x)` | string contains substring, or table contains element (deep) | +| `:toHaveLength(n)` | strings or arrays | +| `:toMatch(pattern)` | Lua pattern, not regex | +| `:toThrow(pattern?)` | calling actual throws; optional pattern matches the error | +| `:toHaveBeenCalled()` | mock called at least once | +| `:toHaveBeenCalledTimes(n)` | exact count | +| `:toHaveBeenCalledWith(...)` | any call deep-equals these args | + +```lua +lib.test.expect(value).never:toBe(0) +lib.test.expect({1,2}).never:toEqual({1,2,3}) +``` + +### Asymmetric matchers + +For partial matches inside `:toEqual` or `:toHaveBeenCalledWith`: + +```lua +lib.test.expect(user):toEqual({ + id = 1, + name = 'alice', + createdAt = lib.test.expect.any('number'), +}) + +lib.test.expect(emit):toHaveBeenCalledWith( + lib.test.expect.objectContaining({ event = 'login' }) +) +``` + +| Matcher | Matches | +| --------------------------------- | --------------------------------------------- | +| `expect.any(typeName)` | any value where `type(v) == typeName` | +| `expect.anything()` | any non-nil | +| `expect.callable()` | any callable: function or table with `__call` | +| `expect.objectContaining(subset)` | table where every key in `subset` matches | +| `expect.arrayContaining(subset)` | array containing every element of `subset` | + +## Mocks + +```lua +local m = lib.test.fn():mockReturnValue(42) +m('hello') +lib.test.expect(m):toHaveBeenCalledWith('hello') +lib.test.expect(m()):toBe(42) +``` + +| API | Effect | +| -------------------------- | ------------------------------- | +| `lib.test.fn(impl?)` | new mock, optional initial impl | +| `m:mockReturnValue(v)` | always return `v` | +| `m:mockImplementation(fn)` | replace impl | +| `m:mockClear()` | wipe call history, keep impl | +| `m:mockReset()` | wipe everything | +| `m.calls` | `any[][]` of every call | +| `m.callCount` | integer | +| `m.lastCall` | last args, or nil | + +## Spies + +`lib.test.spy(obj, key)` patches `obj[key]` with a mock that wraps the original. Auto-restored after the test. + +```lua +lib.test.it('emits a metric', function() + local s = lib.test.spy(Metrics, 'emit') + Inventory:save() + lib.test.expect(s):toHaveBeenCalledWith('inventory.save') +end) +``` + +`mockReturnValue`, `mockImplementation`, etc. work on spies too. + +## Async + +Three styles. Pick one per test. + +```lua +-- 1. Return a promise. +lib.test.it('async', function() + local p = promise.new() + SetTimeout(50, function() p:resolve(42) end) + return p:next(function(v) + lib.test.expect(v):toBe(42) + end, function(err) error(err) end) +end) + +-- 2. done() callback. +lib.test.it('callback', function(done) + SetTimeout(30, function() done() end) +end) + +-- 3. Citizen.Await inline. +lib.test.it('await', function() + local p = promise.new() + SetTimeout(40, function() p:resolve('ok') end) + lib.test.expect(Citizen.Await(p)):toBe('ok') +end) +``` + +### Timeouts + +Default 5000 ms per test. Override per test: + +```lua +lib.test.it('slow', function(done) + SetTimeout(8000, function() done() end) +end, 10000) +``` + +A timeout reports as `timeout`, not `fail`. + +## Run options + +```lua +lib.test.run({ + reporter = 'console', -- 'console' | 'json' | TestReporter table + filter = 'auth', -- substring on 'suite > test' path, case-insensitive + timeout = 5000, -- default per-test timeout (ms) + bail = false, -- stop on first failure +}) +``` + +Returns: + +```lua +{ + passed = 12, + failed = 1, + skipped = 2, + timedOut = 1, + duration = 84.3, + suites = { ... }, + failures = { ... }, -- flat list of failing test results +} +``` + +## API + +| Function | Purpose | +| -------------------------------------------------------------- | ----------------------------------------------------- | +| `lib.test.describe(name, body)` | register a suite | +| `lib.test.it(name, body, timeout?)` | register a test (also `.skip`, `.only`, `.each`) | +| `lib.test.expect(actual)` | chainable assertion; also exposes `expect.any` etc. | +| `lib.test.fn(impl?)` | mock function | +| `lib.test.spy(obj, key)` | wrap method, auto-restore | +| `lib.test.isCallable(v)` | true if `v` is a function or callable table | +| `lib.test.beforeEach` / `afterEach` / `beforeAll` / `afterAll` | hooks | +| `lib.test.discover(resource?)` | recursively load `*.test.lua` from each `ox_test_dir` | +| `lib.test.register(path)` | load one test file by `@resource/path.lua` | +| `lib.test.run(opts?)` | execute the registered tests | +| `lib.test.reset()` | clear the registry | + +The `runOxTests` export gets registered automatically in any resource that declares `ox_test_dir`. That's how `oxtest ` runs tests cross-VM: + +```lua +exports['']:runOxTests(filter, reporter) +``` + +It does `reset` + `discover` + `run` inside the resource's own VM, where its globals exist. + +### Custom reporters + +A reporter is any table. Implement what you need: + +```lua +lib.test.run({ reporter = { + onRunStart = function(_, root) end, + onSuiteStart = function(_, suite, depth) end, + onTestEnd = function(_, test, result, depth) print(result.status, result.path) end, + onSuiteEnd = function(_, suite, depth) end, + onRunEnd = function(_, result) end, +}}) +``` + +## Gotchas + +- Lua patterns, not regex. `toMatch('foo.*bar')` is not regex. +- 50 ms tick floor per test (FiveM scheduler). Pure logic still tests fine, just don't expect microsecond timings. +- `it()` cannot nest inside `it()`. Use `describe`. +- Mocks from `fn()` are not auto-cleared. `m:mockClear()` in `beforeEach` if you reuse them. Spies are auto-restored. +- Promise rejection handler is required: `p:next(onResolve, onReject)`. +- `type(v) == 'function'` is wrong for FiveM cross-resource function references and for `lib.test.fn()` mocks (both are callable tables, so `type()` returns `'table'`). Use `lib.test.isCallable(v)` or assert via `:toBeCallable()` / `expect.callable()` in tests. + +## Troubleshooting + +| Symptom | Cause | +| ------------------------------------------- | ------------------------------------------------------------------------------------------------------------ | +| `No such command oxtest` | ox_lib was started before this feature. Restart it. | +| `resource "x" is missing` | Folder isn't in `resources/`, or you typed the wrong name. FiveM uses folder name, not the `name` directive. | +| `state is "stopped"` | Add `ensure x` to `server.cfg` or run from console. | +| `declares no ox_test_dir entries` | Add `ox_test_dir 'tests'` to the resource's fxmanifest. | +| `0 *.test.lua file(s) found` | Files don't end in `.test.lua` or path is wrong. | +| `failed to invoke runner in "x"` | Resource didn't auto-load `lib.test`. Needs `dependency 'ox_lib'` and at least one `ox_test_dir`. | +| `attempt to index a nil value (global 'X')` | Tests run in the target resource's VM. The global must be defined by that resource's `server_scripts`. | + +## Cookbook + +### Test a class + +```lua +-- server/inventory.lua +Inventory = lib.class('Inventory') +function Inventory:constructor(cap) self.cap = cap; self.items = {} end + +-- tests/inventory.test.lua +lib.test.describe('Inventory', function() + local inv + lib.test.beforeEach(function() inv = Inventory:new(5) end) + + lib.test.it('starts empty', function() + lib.test.expect(inv:count('bread')):toBe(0) + end) +end) +``` + +### Test a function with global side effects + +```lua +lib.test.it('logs on save', function() + local spy = lib.test.spy(_G, 'print') + saveUser({ id = 1 }) + lib.test.expect(spy):toHaveBeenCalledWith('saved user 1') +end) +``` + +### Test an async event handler + +```lua +lib.test.it('updates the counter', function(done) + Stats.reset() + TriggerEvent('myresource:scored', 'alice', 5) + SetTimeout(50, function() + local ok, err = pcall(function() + lib.test.expect(Stats.get('alice')):toBe(5) + end) + done(ok and nil or err) + end) +end) +``` + +### Mock a dependency + +```lua +lib.test.it('routes through metrics', function() + local emit = lib.test.spy(Metrics, 'emit') + Inventory.save({ id = 1 }) + lib.test.expect(emit):toHaveBeenCalledWith( + 'inventory.save', + lib.test.expect.objectContaining({ id = 1 }) + ) +end) +``` + +### Focus on one test while debugging + +```lua +lib.test.it.only('the broken one', function() ... end) +``` + +Remove `.only` before committing. + +### CI + +```lua +local result = exports['my_resource']:runOxTests(nil, 'json') +if not result or result.failed > 0 or result.timedOut > 0 then + print('TESTS FAILED') +end +``` + +## Examples + +In `examples/`. Loaded by `oxtest` with no args. + +| File | Covers | +| ---------------------- | ----------------------------------------------------------------------- | +| `passing.lua` | every matcher, happy path | +| `assertion_errors.lua` | every matcher's failure message, via `:toThrow` | +| `async.lua` | the three async styles, plus failure paths | +| `timeouts.lua` | timeout detection | +| `hooks.lua` | hooks and hook failures | +| `mocks.lua` | `fn` and all mock helpers | +| `spies.lua` | spies, restoration, inherited methods | +| `parameterized.lua` | `it.each` | +| `matchers.lua` | matcher edge cases, asymmetric matchers | +| `run_options.lua` | `it.only`, `bail`, `filter`, custom reporter, JSON, `register`, `reset` | +| `_demo_failures.lua` | not auto-loaded; load manually to see failure output | +| `_register_helper.lua` | not auto-loaded; used by `run_options.lua` | diff --git a/imports/test/discover.lua b/imports/test/discover.lua new file mode 100644 index 000000000..019b0b9c8 --- /dev/null +++ b/imports/test/discover.lua @@ -0,0 +1,78 @@ +-- Recursive *.test.lua discovery from `ox_test_dir` manifest entries, plus +-- single-file register(). + +local TEST_FILE_PATTERN = '%.test%.lua$' + +local M = {} + +---Discover and load test files. Reads `ox_test_dir` metadata entries from the +---resource and recursively scans each declared directory for `*.test.lua`. +---@param resource? string defaults to the calling resource +---@return integer count number of test files successfully loaded +function M.discover(resource) + resource = resource or GetCurrentResourceName() + + local state = GetResourceState(resource) + if state == 'missing' or state == 'unknown' then + lib.print.error(('resource %q is %s, copy it into resources/ and `ensure` it first'):format(resource, state)) + return 0 + end + if state ~= 'started' then + lib.print.warn(('resource %q state is %q (not started), run `ensure %s` first'):format(resource, state, resource)) + return 0 + end + + local dirCount = GetNumResourceMetadata(resource, 'ox_test_dir') or 0 + if dirCount == 0 then + lib.print.warn(('resource %q declares no `ox_test_dir` entries in fxmanifest'):format(resource)) + return 0 + end + + local loaded = 0 + for i = 0, dirCount - 1 do + local dir = GetResourceMetadata(resource, 'ox_test_dir', i) + if dir then + local files = lib.getFilesInDirectory(('@%s/%s'):format(resource, dir), TEST_FILE_PATTERN, true) + print(('^5[ox_lib:test]^7 scanning %s/%s, %d *.test.lua file(s) found'):format(resource, dir, #files)) + for j = 1, #files do + local relPath = ('%s/%s'):format(dir, files[j]) + local file = LoadResourceFile(resource, relPath) + if file then + local chunk, err = load(file, ('@@%s/%s'):format(resource, relPath), 't', _ENV) + if chunk then + local ok, runErr = pcall(chunk) + if ok then + loaded = loaded + 1 + else + lib.print.error(('error registering %s/%s: %s'):format(resource, relPath, runErr)) + end + else + lib.print.error(('failed to compile %s/%s: %s'):format(resource, relPath, err)) + end + end + end + end + end + return loaded +end + +---Load a single test file. Path can be `tests/foo.test.lua` (current resource) +---or `@resource/tests/foo.test.lua` (cross-resource). Loaded raw via +---LoadResourceFile rather than lib.load because lib.load treats `.` as a Lua +---module separator and would mangle the `.lua` extension. +---@param path string +function M.register(path) + if type(path) ~= 'string' then error("register(path): path must be a string", 2) end + local resource, relPath = path:match('^@([^/]+)/(.+)$') + if not resource then + resource = cache.resource + relPath = path + end + local file = LoadResourceFile(resource, relPath) + if not file then error(("test file '%s' not found"):format(path), 2) end + local chunk, err = load(file, ('@@%s/%s'):format(resource, relPath), 't', _ENV) + if not chunk then error(("failed to compile '%s': %s"):format(path, err), 2) end + chunk() +end + +return M diff --git a/imports/test/dsl.lua b/imports/test/dsl.lua new file mode 100644 index 000000000..7a9dc3c2d --- /dev/null +++ b/imports/test/dsl.lua @@ -0,0 +1,70 @@ +-- describe / it / hooks. Pure registration; nothing runs until run() walks +-- the tree. + +local Registry = require '@ox_lib/imports/test/registry' +local helpers = require '@ox_lib/imports/test/helpers' + +---@alias TestBodyDone fun(err?: any) +---@alias TestBody fun(done?: TestBodyDone): any + +---@class OxTestIt +---@overload fun(name: string, body: TestBody, timeout?: integer): nil +---@field skip fun(name: string, body?: TestBody): nil +---@field only fun(name: string, body: TestBody, timeout?: integer): nil +---@field each fun(cases: T[]): fun(nameFmt: string, body: fun(case: T)): nil + +local M = {} + +---@param name string +---@param body fun() +function M.describe(name, body) + if type(name) ~= 'string' then error("describe(name, body): name must be a string", 2) end + if type(body) ~= 'function' then error("describe(name, body): body must be a function", 2) end + Registry.pushSuite(name, body) +end + +local it = setmetatable({}, { + __call = function(_, name, body, timeout) + if type(name) ~= 'string' then error("it(name, body, timeout?): name must be a string", 2) end + if type(body) ~= 'function' then error("it(name, body, timeout?): body must be a function", 2) end + Registry.addTest(name, body, { timeout = timeout }) + end, +}) + +function it.skip(name, body) + Registry.addTest(name, body or function() end, { skipped = true }) +end + +function it.only(name, body, timeout) + Registry.addTest(name, body, { only = true, timeout = timeout }) +end + +function it.each(cases) + return function(nameFmt, body) + if type(nameFmt) ~= 'string' then error("it.each(...)(name, body): name must be a string", 2) end + if type(body) ~= 'function' then error("it.each(...)(name, body): body must be a function", 2) end + for i = 1, #cases do + local case = cases[i] + local name + if type(case) == 'table' then + local args = {} + for j = 1, #case do args[j] = helpers.formatValue(case[j]) end + local ok, formatted = pcall(string.format, nameFmt, table.unpack(args)) + name = ok and formatted or ('%s [%d]'):format(nameFmt, i) + else + local ok, formatted = pcall(string.format, nameFmt, helpers.formatValue(case)) + name = ok and formatted or ('%s [%d]'):format(nameFmt, i) + end + Registry.addTest(name, function() body(case) end, {}) + end + end +end + +M.it = it + +function M.beforeEach(cb) local h = Registry.currentSuite().hooks; h.beforeEach[#h.beforeEach + 1] = cb end +function M.afterEach(cb) local h = Registry.currentSuite().hooks; h.afterEach[#h.afterEach + 1] = cb end +function M.beforeAll(cb) local h = Registry.currentSuite().hooks; h.beforeAll[#h.beforeAll + 1] = cb end +function M.afterAll(cb) local h = Registry.currentSuite().hooks; h.afterAll[#h.afterAll + 1] = cb end + +return M diff --git a/imports/test/examples/_demo_failures.lua b/imports/test/examples/_demo_failures.lua new file mode 100644 index 000000000..226c7b7ed --- /dev/null +++ b/imports/test/examples/_demo_failures.lua @@ -0,0 +1,33 @@ +-- Tests that intentionally fail so you can see how the reporter formats failures. +-- Useful when developing the framework itself. Keep these small and obvious. + +lib.test.describe('failing examples (intentional)', function() + lib.test.it('toBe primitive mismatch', function() + lib.test.expect(1 + 1):toBe(3) + end) + + lib.test.it('toEqual deep mismatch', function() + lib.test.expect({ a = 1, b = 2 }):toEqual({ a = 1, b = 3 }) + end) + + lib.test.it('uncaught runtime error', function() + error('this is an unexpected error from the test body') + end) + + lib.test.it('pcall-protected error message', function() + local _, err = pcall(function() error('inner failure') end) + lib.test.expect(err):toMatch('different pattern') + end) + + lib.test.it('toThrow but nothing thrown', function() + lib.test.expect(function() return 'no error here' end):toThrow() + end) + + lib.test.it('skipped test (informational)', function() + error('this should never run because the next line marks it skipped') + end) + + lib.test.it.skip('explicitly skipped', function() + error('skipped should not run') + end) +end) diff --git a/imports/test/examples/_register_helper.lua b/imports/test/examples/_register_helper.lua new file mode 100644 index 000000000..51c411089 --- /dev/null +++ b/imports/test/examples/_register_helper.lua @@ -0,0 +1,11 @@ +-- Loaded by run_options.lua to verify lib.test.register works. Must define +-- exactly two passing tests (the test asserts result.passed == 2). +-- Underscore-prefixed so the /oxtest demo loop ignores it. + +lib.test.it('registered helper test 1', function() + lib.test.expect(true):toBe(true) +end) + +lib.test.it('registered helper test 2', function() + lib.test.expect(1 + 1):toBe(2) +end) diff --git a/imports/test/examples/assertion_errors.lua b/imports/test/examples/assertion_errors.lua new file mode 100644 index 000000000..f6872f396 --- /dev/null +++ b/imports/test/examples/assertion_errors.lua @@ -0,0 +1,74 @@ +-- Tests that the matcher failure paths produce the right error messages. +-- Pattern: wrap a failing assertion in a function and verify it throws the +-- expected text. Same coverage as the old `failing.lua`, but all passing. + +lib.test.describe('assertion errors', function() + lib.test.it('toBe surfaces a primitive mismatch', function() + lib.test.expect(function() + lib.test.expect(2):toBe(3) + end):toThrow('expected 2 to be 3') + end) + + lib.test.it('toEqual surfaces a deep mismatch', function() + lib.test.expect(function() + lib.test.expect({ a = 1, b = 2 }):toEqual({ a = 1, b = 3 }) + end):toThrow('to equal %(deep%)') + end) + + lib.test.it('toBeTruthy / toBeFalsy / toBeNil throw on the wrong value', function() + lib.test.expect(function() lib.test.expect(false):toBeTruthy() end):toThrow('be truthy') + lib.test.expect(function() lib.test.expect(true):toBeFalsy() end):toThrow('be falsy') + lib.test.expect(function() lib.test.expect(0):toBeNil() end):toThrow('be nil') + end) + + lib.test.it('toBeGreaterThan / toBeLessThan throw on the wrong direction', function() + lib.test.expect(function() lib.test.expect(1):toBeGreaterThan(5) end):toThrow('be greater than') + lib.test.expect(function() lib.test.expect(5):toBeLessThan(1) end):toThrow('be less than') + end) + + lib.test.it('toContain throws when the needle is absent', function() + lib.test.expect(function() lib.test.expect('hello'):toContain('xyz') end):toThrow('contain') + lib.test.expect(function() lib.test.expect({ 'a', 'b' }):toContain('z') end):toThrow('contain') + end) + + lib.test.it('toHaveLength throws on a length mismatch', function() + lib.test.expect(function() lib.test.expect({ 1, 2 }):toHaveLength(5) end):toThrow('have length') + end) + + lib.test.it('toMatch throws when the pattern does not match', function() + lib.test.expect(function() lib.test.expect('hello'):toMatch('xyz') end):toThrow('match Lua pattern') + end) + + lib.test.it('toThrow throws when nothing was thrown', function() + lib.test.expect(function() + lib.test.expect(function() return 'no error' end):toThrow() + end):toThrow('throw') + end) + + lib.test.it('toThrow throws when the error pattern does not match', function() + lib.test.expect(function() + lib.test.expect(function() error('database error') end):toThrow('network') + end):toThrow('throw matching') + end) + + lib.test.it('uncaught error inside a test body becomes the test failure', function() + -- This mirrors what the runner sees a normal Lua error from inside a body. + local ok, err = pcall(function() error('synthetic error from test body') end) + lib.test.expect(ok):toBe(false) + lib.test.expect(err):toMatch('synthetic error from test body') + end) + + lib.test.it('.never inverts the matcher: throws when the assertion would pass', function() + lib.test.expect(function() + lib.test.expect(2).never:toBe(2) + end):toThrow('not to be 2') + end) + + lib.test.it('mock not-called assertion throws when the mock was called', function() + local m = lib.test.fn() + m() + lib.test.expect(function() + lib.test.expect(m).never:toHaveBeenCalled() + end):toThrow('have been called') + end) +end) diff --git a/imports/test/examples/async.lua b/imports/test/examples/async.lua new file mode 100644 index 000000000..0d31d0d1c --- /dev/null +++ b/imports/test/examples/async.lua @@ -0,0 +1,64 @@ +-- Three flavors of async tests: +-- 1. Return a promise runner awaits it. +-- 2. Use the `done` callback call done() to pass, done(err) to fail. +-- 3. Citizen.Await inside the body synchronous-looking, still async under the hood. +-- +-- Failure cases (rejected promise, done(err)) are tested via runIsolated below +-- so the assertions about *failure* are themselves passing tests. + +lib.test.describe('async tests', function() + lib.test.it('returned promise resolves', function() + local p = promise.new() + SetTimeout(50, function() p:resolve(42) end) + return p:next(function(value) + lib.test.expect(value):toBe(42) + end, function(err) error(err) end) + end) + + lib.test.it('done() callback happy path', function(done) + SetTimeout(30, function() + local ok = (1 + 2 == 3) + done(not ok and 'math is broken' or nil) + end) + end) + + lib.test.it('Citizen.Await inside body', function() + local p = promise.new() + SetTimeout(40, function() p:resolve('inline') end) + local result = Citizen.Await(p) + lib.test.expect(result):toBe('inline') + end) + + lib.test.describe('failure paths', function() + lib.test.it('a rejected returned promise marks the test failed', function() + local result = lib.test.runIsolated(function() + lib.test.it('rejects', function() + local p = promise.new() + SetTimeout(10, function() p:reject('async error') end) + return p + end) + end) + lib.test.expect(result.failed):toBe(1) + lib.test.expect(result.passed):toBe(0) + lib.test.expect(result.failures[1].error):toMatch('async error') + end) + + lib.test.it('done(err) marks the test failed', function() + local result = lib.test.runIsolated(function() + lib.test.it('done with err', function(done) + SetTimeout(10, function() done('explicit error') end) + end) + end) + lib.test.expect(result.failed):toBe(1) + lib.test.expect(result.failures[1].error):toMatch('explicit error') + end) + + lib.test.it('a synchronous error in the body marks the test failed', function() + local result = lib.test.runIsolated(function() + lib.test.it('throws', function() error('boom') end) + end) + lib.test.expect(result.failed):toBe(1) + lib.test.expect(result.failures[1].error):toMatch('boom') + end) + end) +end) diff --git a/imports/test/examples/hooks.lua b/imports/test/examples/hooks.lua new file mode 100644 index 000000000..35cb787e0 --- /dev/null +++ b/imports/test/examples/hooks.lua @@ -0,0 +1,85 @@ +-- beforeAll / afterAll run once per suite. beforeEach / afterEach wrap every test. +-- Hooks accumulate up the suite tree: outer beforeEach runs first, inner runs second. + +lib.test.describe('hooks', function() + local counter + + lib.test.beforeAll(function() + counter = { value = 0, beforeAllRan = true } + end) + + lib.test.beforeEach(function() + counter.value = counter.value + 1 + end) + + lib.test.afterEach(function() + -- runs even when the test fails + end) + + lib.test.afterAll(function() + counter = nil + end) + + lib.test.it('beforeAll initialised state', function() + lib.test.expect(counter.beforeAllRan):toBe(true) + end) + + lib.test.it('beforeEach incremented before this test', function() + lib.test.expect(counter.value):toBeGreaterThan(0) + end) + + lib.test.describe('nested suite', function() + local nestedCounter = 0 + + lib.test.beforeEach(function() + nestedCounter = nestedCounter + 1 + end) + + lib.test.it('outer + inner beforeEach both run', function() + lib.test.expect(counter.value):toBeGreaterThan(0) + lib.test.expect(nestedCounter):toBeGreaterThan(0) + end) + end) + + lib.test.describe('hook failures', function() + lib.test.it('a throwing beforeEach marks each test in the suite as failed', function() + local result = lib.test.runIsolated(function() + lib.test.describe('s', function() + lib.test.beforeEach(function() error('beforeEach blew up') end) + lib.test.it('a', function() end) + lib.test.it('b', function() end) + end) + end) + lib.test.expect(result.failed):toBe(2) + lib.test.expect(result.passed):toBe(0) + lib.test.expect(result.failures[1].error):toMatch('beforeEach failed') + end) + + lib.test.it('a throwing beforeAll fails every test in the suite', function() + local result = lib.test.runIsolated(function() + lib.test.describe('s', function() + lib.test.beforeAll(function() error('beforeAll blew up') end) + lib.test.it('a', function() end) + lib.test.it('b', function() end) + lib.test.it('c', function() end) + end) + end) + lib.test.expect(result.failed):toBe(3) + lib.test.expect(result.failures[1].error):toMatch('beforeAll failed') + end) + + lib.test.it('a throwing afterEach is collected alongside test results', function() + local result = lib.test.runIsolated(function() + lib.test.describe('s', function() + lib.test.afterEach(function() error('afterEach blew up') end) + lib.test.it('passes its own assertions', function() + lib.test.expect(1):toBe(1) + end) + end) + end) + -- The test itself passed, but afterEach failed should be marked failed. + lib.test.expect(result.failed):toBe(1) + lib.test.expect(result.failures[1].error):toMatch('afterEach failed') + end) + end) +end) diff --git a/imports/test/examples/matchers.lua b/imports/test/examples/matchers.lua new file mode 100644 index 000000000..2ae215de0 --- /dev/null +++ b/imports/test/examples/matchers.lua @@ -0,0 +1,134 @@ +-- Edge cases for matchers cycles, mixed types, empty values, Lua patterns. + +lib.test.describe('matcher edge cases', function() + lib.test.it('toEqual handles cyclic tables', function() + local a = { name = 'a' } + local b = { name = 'b' } + a.peer = b + b.peer = a + + local x = { name = 'a' } + local y = { name = 'b' } + x.peer = y + y.peer = x + + lib.test.expect(a):toEqual(x) + end) + + lib.test.it('toEqual differentiates extra keys', function() + lib.test.expect({ 1, 2 }).never:toEqual({ 1, 2, 3 }) + lib.test.expect({ a = 1 }).never:toEqual({ a = 1, b = 2 }) + end) + + lib.test.it('toMatch uses Lua patterns, not regex', function() + lib.test.expect('foo123bar'):toMatch('%a+%d+%a+') + lib.test.expect('plain text'):toMatch('text$') + end) + + lib.test.it('toContain on empty collections', function() + lib.test.expect({}).never:toContain('anything') + lib.test.expect('').never:toContain('x') + end) + + lib.test.it('toContain uses deep equality for table elements', function() + lib.test.expect({ { id = 1 }, { id = 2 } }):toContain({ id = 2 }) + lib.test.expect({ { id = 1 } }).never:toContain({ id = 99 }) + end) + + lib.test.it('toHaveBeenCalledWith uses deep equality on table args', function() + local m = lib.test.fn() + m({ user = 'alice', score = 5 }) + lib.test.expect(m):toHaveBeenCalledWith({ user = 'alice', score = 5 }) + lib.test.expect(m).never:toHaveBeenCalledWith({ user = 'alice', score = 99 }) + end) + + lib.test.it('toThrow with a pattern that does not match', function() + local fn = function() error('database connection refused') end + lib.test.expect(fn):toThrow('connection refused') + lib.test.expect(fn).never:toThrow('timeout') + end) + + lib.test.it('toBeCloseTo precision', function() + lib.test.expect(0.1 + 0.2):toBeCloseTo(0.3, 5) + lib.test.expect(0.1 + 0.2).never:toBe(0.3) -- floating point + end) + + lib.test.describe('callable detection', function() + lib.test.it('isCallable accepts plain functions', function() + lib.test.expect(lib.test.isCallable(function() end)):toBe(true) + end) + + lib.test.it('isCallable accepts callable tables (mocks, function refs)', function() + local fnRef = setmetatable({}, { __call = function() end }) + lib.test.expect(lib.test.isCallable(fnRef)):toBe(true) + lib.test.expect(lib.test.isCallable(lib.test.fn())):toBe(true) + end) + + lib.test.it('isCallable rejects non-callables', function() + lib.test.expect(lib.test.isCallable(nil)):toBe(false) + lib.test.expect(lib.test.isCallable(42)):toBe(false) + lib.test.expect(lib.test.isCallable('hello')):toBe(false) + lib.test.expect(lib.test.isCallable({})):toBe(false) + lib.test.expect(lib.test.isCallable({ __call = function() end })):toBe(false) -- __call must be in the metatable + end) + + lib.test.it('toBeCallable matches functions and callable tables', function() + lib.test.expect(function() end):toBeCallable() + lib.test.expect(lib.test.fn()):toBeCallable() + lib.test.expect(setmetatable({}, { __call = function() end })):toBeCallable() + lib.test.expect({}).never:toBeCallable() + lib.test.expect(42).never:toBeCallable() + end) + + lib.test.it('expect.callable() works inside toEqual / toHaveBeenCalledWith', function() + local handler = setmetatable({}, { __call = function() end }) + lib.test.expect({ id = 1, on = handler }):toEqual({ + id = 1, + on = lib.test.expect.callable(), + }) + + local register = lib.test.fn() + register('login', function() end) + lib.test.expect(register):toHaveBeenCalledWith('login', lib.test.expect.callable()) + end) + + lib.test.it('toThrow accepts callable tables, not just functions', function() + local thrower = setmetatable({}, { + __call = function() error('boom from callable table') end, + }) + lib.test.expect(thrower):toThrow('boom') + end) + end) + + lib.test.describe('asymmetric matchers', function() + lib.test.it('expect.any matches by Lua type', function() + lib.test.expect({ id = 1, name = 'alice' }):toEqual({ + id = lib.test.expect.any('number'), + name = lib.test.expect.any('string'), + }) + end) + + lib.test.it('expect.anything ignores the value but rejects nil', function() + lib.test.expect({ value = 0 }):toEqual({ value = lib.test.expect.anything() }) + lib.test.expect({ value = nil }).never:toEqual({ value = lib.test.expect.anything() }) + end) + + lib.test.it('expect.objectContaining matches a superset', function() + local user = { id = 1, name = 'alice', createdAt = 12345 } + lib.test.expect(user):toEqual(lib.test.expect.objectContaining({ id = 1, name = 'alice' })) + end) + + lib.test.it('expect.arrayContaining matches sub-elements in any order', function() + lib.test.expect({ 'apple', 'banana', 'cherry' }) + :toEqual(lib.test.expect.arrayContaining({ 'cherry', 'apple' })) + end) + + lib.test.it('compose with toHaveBeenCalledWith', function() + local fn = lib.test.fn() + fn({ event = 'login', userId = 42 }) + lib.test.expect(fn):toHaveBeenCalledWith( + lib.test.expect.objectContaining({ event = 'login' }) + ) + end) + end) +end) diff --git a/imports/test/examples/mocks.lua b/imports/test/examples/mocks.lua new file mode 100644 index 000000000..e8626727b --- /dev/null +++ b/imports/test/examples/mocks.lua @@ -0,0 +1,64 @@ +-- lib.test.fn() creates a callable mock that records every call. +-- It supports mockReturnValue, mockImplementation, mockClear, mockReset. + +lib.test.describe('mock functions (lib.test.fn)', function() + lib.test.it('records calls and arguments', function() + local m = lib.test.fn() + m('alice', 1) + m('bob', 2) + lib.test.expect(m):toHaveBeenCalled() + lib.test.expect(m):toHaveBeenCalledTimes(2) + lib.test.expect(m):toHaveBeenCalledWith('alice', 1) + lib.test.expect(m.calls):toHaveLength(2) + lib.test.expect(m.lastCall):toEqual({ 'bob', 2 }) + end) + + lib.test.it('mockReturnValue overrides return', function() + local m = lib.test.fn():mockReturnValue(99) + lib.test.expect(m()):toBe(99) + lib.test.expect(m('ignored')):toBe(99) + end) + + lib.test.it('fn(impl) accepts an implementation in the constructor', function() + local m = lib.test.fn(function(x) return x + 1 end) + lib.test.expect(m(10)):toBe(11) + lib.test.expect(m):toHaveBeenCalledWith(10) + end) + + lib.test.it('mockImplementation runs custom logic', function() + local m = lib.test.fn():mockImplementation(function(a, b) return a * b end) + lib.test.expect(m(3, 4)):toBe(12) + lib.test.expect(m):toHaveBeenCalledWith(3, 4) + end) + + lib.test.it('toHaveBeenCalledTimes(0) holds for an unused mock', function() + local m = lib.test.fn() + lib.test.expect(m):toHaveBeenCalledTimes(0) + lib.test.expect(m).never:toHaveBeenCalled() + end) + + lib.test.it('.never:toHaveBeenCalledWith asserts a specific call did not happen', function() + local m = lib.test.fn() + m('alice') + m('bob') + lib.test.expect(m).never:toHaveBeenCalledWith('carol') + lib.test.expect(m):toHaveBeenCalledWith('alice') + end) + + lib.test.it('mockClear wipes call history but keeps impl', function() + local m = lib.test.fn():mockReturnValue('x') + m(); m(); m() + m:mockClear() + lib.test.expect(m.callCount):toBe(0) + lib.test.expect(m()):toBe('x') + end) + + lib.test.it('mockReset wipes everything including impl', function() + local m = lib.test.fn():mockReturnValue('x') + m() + m:mockReset() + lib.test.expect(m.callCount):toBe(0) + lib.test.expect(m()):toBeNil() + end) + +end) diff --git a/imports/test/examples/parameterized.lua b/imports/test/examples/parameterized.lua new file mode 100644 index 000000000..99b3b1b08 --- /dev/null +++ b/imports/test/examples/parameterized.lua @@ -0,0 +1,28 @@ +-- it.each(cases)(nameFmt, body) runs the same body once per case. +-- nameFmt is a string.format pattern; %s is replaced with each table element. + +lib.test.describe('parameterized tests', function() + lib.test.it.each({ + { 1, 1, 2 }, + { 2, 3, 5 }, + { 10, 20, 30 }, + })('adds %s + %s = %s', function(case) + lib.test.expect(case[1] + case[2]):toBe(case[3]) + end) + + lib.test.it.each({ + 'apple', + 'banana', + 'cherry', + })('string %s is non-empty', function(case) + lib.test.expect(#case):toBeGreaterThan(0) + end) + + lib.test.it.each({ + { 'a', 0 }, + { 'b', 1 }, + { 'c', 2 }, + })('letter %s is at offset %s from a', function(case) + lib.test.expect(case[1]:byte() - ('a'):byte()):toBe(case[2]) + end) +end) diff --git a/imports/test/examples/passing.lua b/imports/test/examples/passing.lua new file mode 100644 index 000000000..7ced959ed --- /dev/null +++ b/imports/test/examples/passing.lua @@ -0,0 +1,44 @@ +-- Tests that should all pass sanity checks on the framework itself. + +lib.test.describe('passing examples', function() + lib.test.it('compares primitives with toBe', function() + lib.test.expect(1 + 1):toBe(2) + lib.test.expect('hello'):toBe('hello') + lib.test.expect(true):toBe(true) + end) + + lib.test.it('does deep equality with toEqual', function() + lib.test.expect({ a = 1, b = { 2, 3 } }):toEqual({ a = 1, b = { 2, 3 } }) + end) + + lib.test.it('supports the .never modifier', function() + lib.test.expect(1).never:toBe(2) + lib.test.expect({ 1, 2 }).never:toEqual({ 1, 2, 3 }) + end) + + lib.test.it('truthiness checks', function() + lib.test.expect('any string'):toBeTruthy() + lib.test.expect(0):toBeTruthy() + lib.test.expect(false):toBeFalsy() + lib.test.expect(nil):toBeNil() + end) + + lib.test.it('numeric comparisons', function() + lib.test.expect(10):toBeGreaterThan(5) + lib.test.expect(3):toBeLessThan(7) + lib.test.expect(1.0001):toBeCloseTo(1, 3) + end) + + lib.test.it('strings and tables', function() + lib.test.expect('the quick brown fox'):toContain('quick') + lib.test.expect({ 'apple', 'banana' }):toContain('banana') + lib.test.expect('abcd'):toHaveLength(4) + lib.test.expect({ 1, 2, 3 }):toHaveLength(3) + lib.test.expect('foo123'):toMatch('%d+') + end) + + lib.test.it('toThrow catches errors', function() + lib.test.expect(function() error('boom') end):toThrow('boom') + lib.test.expect(function() return 1 end).never:toThrow() + end) +end) diff --git a/imports/test/examples/run_options.lua b/imports/test/examples/run_options.lua new file mode 100644 index 000000000..d64081339 --- /dev/null +++ b/imports/test/examples/run_options.lua @@ -0,0 +1,188 @@ +-- Run-level options: it.only, bail, filter, custom reporter, JSON reporter, +-- register, reset. Each test uses runIsolated so the option's effect on the +-- whole run can be observed without disturbing the outer suite. + +lib.test.describe('run options', function() + + lib.test.describe('it.only', function() + lib.test.it('skips non-only tests when any only is registered', function() + local result = lib.test.runIsolated(function() + lib.test.it('not run', function() error('should be skipped') end) + lib.test.it.only('focused', function() end) + lib.test.it('also not run', function() error('should be skipped') end) + end) + lib.test.expect(result.passed):toBe(1) + lib.test.expect(result.skipped):toBe(2) + lib.test.expect(result.failed):toBe(0) + end) + + lib.test.it('only-mode visits only suites that contain a focused test', function() + local result = lib.test.runIsolated(function() + lib.test.describe('group A', function() + lib.test.it('a1', function() error('whole suite should be skipped') end) + end) + lib.test.describe('group B', function() + lib.test.it.only('b1', function() end) + lib.test.it('b2', function() error('non-focused sibling') end) + end) + end) + -- group A is skipped entirely (not iterated, so no skipped count for a1). + -- Inside group B, only b1 runs; b2 is iterated and counted as skipped. + lib.test.expect(result.passed):toBe(1) + lib.test.expect(result.skipped):toBe(1) + lib.test.expect(result.failed):toBe(0) + end) + end) + + lib.test.describe('it.skip', function() + lib.test.it('skipped tests are not executed', function() + local result = lib.test.runIsolated(function() + lib.test.it.skip('disabled', function() error('should be skipped') end) + lib.test.it('runs', function() end) + end) + lib.test.expect(result.passed):toBe(1) + lib.test.expect(result.skipped):toBe(1) + end) + end) + + lib.test.describe('bail', function() + lib.test.it('stops on the first failure when bail = true', function() + local result = lib.test.runIsolated(function() + lib.test.it('passes', function() end) + lib.test.it('fails', function() error('boom') end) + lib.test.it('would also pass', function() end) + end, { bail = true }) + lib.test.expect(result.passed):toBe(1) + lib.test.expect(result.failed):toBe(1) + -- third test never ran (was not registered as skipped or passed) + local total = result.passed + result.failed + result.skipped + result.timedOut + lib.test.expect(total):toBe(2) + end) + + lib.test.it('runs everything when bail is omitted', function() + local result = lib.test.runIsolated(function() + lib.test.it('passes', function() end) + lib.test.it('fails', function() error('boom') end) + lib.test.it('also passes',function() end) + end) + lib.test.expect(result.passed):toBe(2) + lib.test.expect(result.failed):toBe(1) + end) + end) + + lib.test.describe('filter', function() + lib.test.it('only runs tests whose path matches the substring', function() + local result = lib.test.runIsolated(function() + lib.test.describe('auth', function() + lib.test.it('login works', function() end) + lib.test.it('logout works', function() end) + end) + lib.test.describe('inventory', function() + lib.test.it('add works', function() end) + end) + end, { filter = 'auth' }) + lib.test.expect(result.passed):toBe(2) + lib.test.expect(result.skipped):toBe(1) + end) + + lib.test.it('filter is case-insensitive', function() + local result = lib.test.runIsolated(function() + lib.test.it('LoginFlow handles errors', function() end) + lib.test.it('something else', function() end) + end, { filter = 'loginflow' }) + lib.test.expect(result.passed):toBe(1) + lib.test.expect(result.skipped):toBe(1) + end) + end) + + lib.test.describe('custom reporter', function() + lib.test.it('invokes reporter callbacks at the right times', function() + local events = {} + local reporter = { + onRunStart = function() events[#events + 1] = 'runStart' end, + onSuiteStart = function() events[#events + 1] = 'suiteStart' end, + onTestEnd = function() events[#events + 1] = 'testEnd' end, + onSuiteEnd = function() events[#events + 1] = 'suiteEnd' end, + onRunEnd = function() events[#events + 1] = 'runEnd' end, + } + lib.test.runIsolated(function() + lib.test.describe('s', function() + lib.test.it('a', function() end) + lib.test.it('b', function() end) + end) + end, { reporter = reporter }) + lib.test.expect(events):toEqual({ + 'runStart', 'suiteStart', 'testEnd', 'testEnd', 'suiteEnd', 'runEnd', + }) + end) + + lib.test.it('a reporter without all callbacks still works (silent reporter)', function() + -- runIsolated's default is an empty reporter table every method + -- is nil. The run still completes and produces a TestResult. + local result = lib.test.runIsolated(function() + lib.test.it('x', function() end) + end) + lib.test.expect(result.passed):toBe(1) + end) + end) + + lib.test.describe('json reporter', function() + lib.test.it('produces parseable JSON output', function() + -- The json reporter prints the encoded payload. Spy on print so we + -- can capture it and round-trip through json.decode. + local captured = {} + local printSpy = lib.test.spy(_G, 'print') + printSpy:mockImplementation(function(...) + local parts = { ... } + for i = 1, select('#', ...) do parts[i] = tostring(parts[i]) end + captured[#captured + 1] = table.concat(parts, '\t') + return nil + end) + + lib.test.runIsolated(function() + lib.test.describe('demo', function() + lib.test.it('passes', function() end) + lib.test.it('fails', function() error('intentional') end) + end) + end, { reporter = 'json' }) + + -- The JSON payload is printed in one print() call. Find it. + local payload + for i = 1, #captured do + local ok, decoded = pcall(json.decode, captured[i]) + if ok and type(decoded) == 'table' and decoded.suites then + payload = decoded + break + end + end + + lib.test.expect(payload).never:toBeNil() + lib.test.expect(payload.passed):toBe(1) + lib.test.expect(payload.failed):toBe(1) + end) + end) + + lib.test.describe('register', function() + lib.test.it('register loads a file and registers its tests', function() + -- _register_helper.lua adds two tests to the registry when loaded. + local result = lib.test.runIsolated(function() + lib.test.register('@ox_lib/imports/test/examples/_register_helper.lua') + end) + lib.test.expect(result.passed):toBe(2) + end) + end) + + lib.test.describe('reset', function() + lib.test.it('reset wipes any previously registered tests', function() + -- Inside the isolated run we register a test, then reset, then + -- register a different one. Only the second should run. + local result = lib.test.runIsolated(function() + lib.test.it('first', function() error('should not run') end) + lib.test.reset() + lib.test.it('second', function() end) + end) + lib.test.expect(result.passed):toBe(1) + lib.test.expect(result.failed):toBe(0) + end) + end) +end) diff --git a/imports/test/examples/spies.lua b/imports/test/examples/spies.lua new file mode 100644 index 000000000..36ad83780 --- /dev/null +++ b/imports/test/examples/spies.lua @@ -0,0 +1,57 @@ +-- lib.test.spy(obj, key) replaces obj[key] with a mock that wraps the original. +-- Spies are auto-restored after each test ends, so suites stay isolated. + +local Inventory = { + add = function(self, item, qty) self.items[item] = (self.items[item] or 0) + qty end, + count = function(self, item) return self.items[item] or 0 end, +} + +local function newInventory() + return setmetatable({ items = {} }, { __index = Inventory }) +end + +lib.test.describe('spies', function() + lib.test.it('spies on a method and forwards to original', function() + local inv = newInventory() + local spy = lib.test.spy(Inventory, 'add') + + inv:add('bread', 2) + inv:add('milk', 1) + + lib.test.expect(spy):toHaveBeenCalledTimes(2) + lib.test.expect(spy.calls[1][2]):toBe('bread') + lib.test.expect(spy.calls[1][3]):toBe(2) + -- original ran too: items table updated + lib.test.expect(inv:count('bread')):toBe(2) + end) + + lib.test.it('spy is restored after the previous test', function() + -- After the previous test ended, Inventory.add was restored to the + -- original function. A live mock would be a callable table, so the + -- type() check is the cleanest way to verify restoration. + lib.test.expect(type(Inventory.add)):toBe('function') + end) + + lib.test.it('mockReturnValue on a spy short-circuits the original', function() + local inv = newInventory() + local spy = lib.test.spy(Inventory, 'count') + spy:mockReturnValue(999) + + lib.test.expect(inv:count('anything')):toBe(999) + lib.test.expect(spy):toHaveBeenCalledWith(inv, 'anything') + end) + + lib.test.it('spies on inherited methods (with warning)', function() + -- The instance only has its own `items` field; `add` is inherited from + -- the metatable's __index. Spy should still patch it on the instance + -- and a warning is printed (visible above this line in the console). + local inv = newInventory() + local spy = lib.test.spy(inv, 'add') + + inv:add('cheese', 1) + + lib.test.expect(spy):toHaveBeenCalledTimes(1) + lib.test.expect(spy.calls[1][2]):toBe('cheese') + lib.test.expect(inv:count('cheese')):toBe(1) + end) +end) diff --git a/imports/test/examples/timeouts.lua b/imports/test/examples/timeouts.lua new file mode 100644 index 000000000..930a4a881 --- /dev/null +++ b/imports/test/examples/timeouts.lua @@ -0,0 +1,60 @@ +-- Timeouts: the runner races each test against a per-test timeout (default 5000ms). +-- These tests use runIsolated so the assertion about *timeout detection* is +-- itself a passing test, not a real timeout in the outer run. + +lib.test.describe('timeouts', function() + lib.test.it('a test that never calls done() is marked as timeout', function() + local result = lib.test.runIsolated(function() + -- never invoke the done callback runner should detect timeout + lib.test.it('hangs', function(_) end, 100) + end) + lib.test.expect(result.timedOut):toBe(1) + lib.test.expect(result.passed):toBe(0) + lib.test.expect(result.failures[1].status):toBe('timeout') + lib.test.expect(result.failures[1].error):toMatch('TIMEOUT') + end) + + lib.test.it('a returned promise that never settles times out', function() + local result = lib.test.runIsolated(function() + lib.test.it('promise never settles', function() return promise.new() end, 100) + end) + lib.test.expect(result.timedOut):toBe(1) + end) + + lib.test.it('Citizen.Await on an unresolved promise times out', function() + local result = lib.test.runIsolated(function() + lib.test.it('await unresolved', function() + Citizen.Await(promise.new()) + end, 100) + end) + lib.test.expect(result.timedOut):toBe(1) + end) + + lib.test.it('a test that resolves in time passes', function() + local result = lib.test.runIsolated(function() + lib.test.it('quick', function(done) + SetTimeout(20, function() done() end) + end, 200) + end) + lib.test.expect(result.passed):toBe(1) + lib.test.expect(result.timedOut):toBe(0) + end) + + lib.test.it('per-test timeout overrides the run-level default', function() + -- run() default is 5000ms; we lower it to 50ms via opts and the per-test + -- override of 500ms wins, so the test passes. + local result = lib.test.runIsolated(function() + lib.test.it('takes 100ms but allows 500ms', function(done) + SetTimeout(100, function() done() end) + end, 500) + end, { timeout = 50 }) + lib.test.expect(result.passed):toBe(1) + end) + + lib.test.it('the run-level timeout default applies when the test does not set one', function() + local result = lib.test.runIsolated(function() + lib.test.it('no override', function(_) end) -- never resolves + end, { timeout = 80 }) + lib.test.expect(result.timedOut):toBe(1) + end) +end) diff --git a/imports/test/expect.lua b/imports/test/expect.lua new file mode 100644 index 000000000..5bb5bc738 --- /dev/null +++ b/imports/test/expect.lua @@ -0,0 +1,234 @@ +-- Matchers (toBe, toEqual, ...), the chainable .never modifier, and asymmetric +-- matchers (expect.any, expect.objectContaining, ...). + +local helpers = require '@ox_lib/imports/test/helpers' +local formatValue = helpers.formatValue +local deepEqual = helpers.deepEqual +local isCallable = helpers.isCallable + +local Matchers = {} + +---@param negated boolean +---@param pass boolean +---@param matcher string +---@param actual any +---@param expected any +---@param hint? string +local function asserts(negated, pass, matcher, actual, expected, hint) + if negated then pass = not pass end + if pass then return end + local nv = negated and 'not ' or '' + local msg + if hint then + msg = ('expected %s %s%s'):format(formatValue(actual), nv, hint) + elseif expected ~= nil then + msg = ('expected %s %sto %s %s'):format(formatValue(actual), nv, matcher, formatValue(expected)) + else + msg = ('expected %s %sto %s'):format(formatValue(actual), nv, matcher) + end + error('AssertionError: ' .. msg, 0) +end + +function Matchers:toBe(expected) + asserts(self._negated, rawequal(self.actual, expected), 'be', self.actual, expected) +end + +function Matchers:toEqual(expected) + asserts(self._negated, deepEqual(self.actual, expected), 'equal (deep)', self.actual, expected) +end + +function Matchers:toBeTruthy() + asserts(self._negated, self.actual ~= nil and self.actual ~= false, 'be truthy', self.actual, nil, 'be truthy') +end + +function Matchers:toBeFalsy() + asserts(self._negated, self.actual == nil or self.actual == false, 'be falsy', self.actual, nil, 'be falsy') +end + +function Matchers:toBeNil() + asserts(self._negated, self.actual == nil, 'be nil', self.actual, nil, 'be nil') +end + +function Matchers:toBeGreaterThan(n) + asserts(self._negated, type(self.actual) == 'number' and self.actual > n, 'be greater than', self.actual, n) +end + +function Matchers:toBeLessThan(n) + asserts(self._negated, type(self.actual) == 'number' and self.actual < n, 'be less than', self.actual, n) +end + +function Matchers:toBeCloseTo(n, decimals) + decimals = decimals or 2 + local diff = math.abs((self.actual or 0) - n) + asserts(self._negated, diff < 10 ^ -decimals * 0.5, 'be close to', self.actual, n, + ('be close to %s (within %d decimals)'):format(formatValue(n), decimals)) +end + +function Matchers:toBeCallable() + asserts(self._negated, isCallable(self.actual), 'be callable', self.actual, nil, 'be callable') +end + +function Matchers:toContain(needle) + local pass = false + if type(self.actual) == 'string' and type(needle) == 'string' then + pass = self.actual:find(needle, 1, true) ~= nil + elseif type(self.actual) == 'table' then + for _, v in pairs(self.actual) do + if v == needle or deepEqual(v, needle) then + pass = true + break + end + end + end + asserts(self._negated, pass, 'contain', self.actual, needle) +end + +function Matchers:toHaveLength(n) + local len + if type(self.actual) == 'string' then + len = #self.actual + elseif type(self.actual) == 'table' then + len = #self.actual + end + asserts(self._negated, len == n, 'have length', self.actual, n, + ('have length %s (received %s)'):format(formatValue(n), formatValue(len))) +end + +function Matchers:toMatch(pattern) + asserts(self._negated, type(self.actual) == 'string' and self.actual:find(pattern) ~= nil, + 'match', self.actual, pattern, ('match Lua pattern %s'):format(formatValue(pattern))) +end + +function Matchers:toThrow(pattern) + if not isCallable(self.actual) then + error('AssertionError: expected actual to be callable for toThrow', 0) + end + local ok, err = pcall(self.actual) + local pass + if pattern then + pass = (not ok) and type(err) == 'string' and err:find(pattern) ~= nil + asserts(self._negated, pass, 'throw', '', pattern, + ('throw matching %s (got %s)'):format(formatValue(pattern), ok and 'no error' or formatValue(err))) + else + pass = not ok + asserts(self._negated, pass, 'throw', '', nil, + ('throw (got %s)'):format(ok and 'no error' or 'error: ' .. tostring(err))) + end +end + +function Matchers:toHaveBeenCalled() + if type(self.actual) ~= 'table' or not self.actual._isMock then + error('AssertionError: expected actual to be a mock function (lib.test.fn)', 0) + end + asserts(self._negated, self.actual.callCount > 0, 'have been called', '', nil, + ('have been called (was called %d times)'):format(self.actual.callCount)) +end + +function Matchers:toHaveBeenCalledTimes(n) + if type(self.actual) ~= 'table' or not self.actual._isMock then + error('AssertionError: expected actual to be a mock function (lib.test.fn)', 0) + end + asserts(self._negated, self.actual.callCount == n, 'have been called times', '', n, + ('have been called %d times (was called %d)'):format(n, self.actual.callCount)) +end + +function Matchers:toHaveBeenCalledWith(...) + if type(self.actual) ~= 'table' or not self.actual._isMock then + error('AssertionError: expected actual to be a mock function (lib.test.fn)', 0) + end + local expected = { ... } + local pass = false + for i = 1, #self.actual.calls do + if deepEqual(self.actual.calls[i], expected) then pass = true; break end + end + asserts(self._negated, pass, 'have been called with', '', expected) +end + +---@generic T +---@param actual T +---@return Expect +local function makeExpect(actual) + -- `.never` is a property, not a method: `expect(x).never:toBe(y)` reads + -- `.never` as a fresh negated Expect, then `:toBe` is the method call. + local self = { actual = actual, _negated = false } + return setmetatable(self, { + __index = function(_, key) + if key == 'never' then + local neg = { actual = actual, _negated = true } + return setmetatable(neg, { __index = function(_, k) return Matchers[k] end }) + end + return Matchers[key] + end, + }) +end + +-- Asymmetric matchers --------------------------------------------------------- + +local function expectAny(luaType) + return { + _isAsymmetric = true, + match = function(_, value) return type(value) == luaType end, + toString = function() return ('expect.any(%s)'):format(luaType) end, + } +end + +local function expectAnything() + return { + _isAsymmetric = true, + match = function(_, value) return value ~= nil end, + toString = function() return 'expect.anything()' end, + } +end + +local function expectCallable() + return { + _isAsymmetric = true, + match = function(_, value) return isCallable(value) end, + toString = function() return 'expect.callable()' end, + } +end + +local function expectObjectContaining(subset) + return { + _isAsymmetric = true, + match = function(_, value) + if type(value) ~= 'table' then return false end + for k, v in pairs(subset) do + if not deepEqual(v, value[k]) then return false end + end + return true + end, + toString = function() return ('expect.objectContaining(%s)'):format(formatValue(subset)) end, + } +end + +local function expectArrayContaining(subset) + return { + _isAsymmetric = true, + match = function(_, value) + if type(value) ~= 'table' then return false end + for i = 1, #subset do + local needle = subset[i] + local found = false + for j = 1, #value do + if deepEqual(needle, value[j]) then found = true; break end + end + if not found then return false end + end + return true + end, + toString = function() return ('expect.arrayContaining(%s)'):format(formatValue(subset)) end, + } +end + +local expect = setmetatable({ + any = expectAny, + anything = expectAnything, + callable = expectCallable, + objectContaining = expectObjectContaining, + arrayContaining = expectArrayContaining, +}, { + __call = function(_, actual) return makeExpect(actual) end, +}) + +return expect diff --git a/imports/test/helpers.lua b/imports/test/helpers.lua new file mode 100644 index 000000000..ea610f93c --- /dev/null +++ b/imports/test/helpers.lua @@ -0,0 +1,104 @@ +-- Pure functions used across the test framework. No state. + +local M = {} + +---@param v any +---@return boolean +-- Returns true for plain functions and for tables with a __call metamethod +-- (FiveM cross-resource function refs, lib.test.fn() / spy() mocks). +function M.isCallable(v) + local t = type(v) + if t == 'function' then return true end + if t == 'table' then + local mt = getmetatable(v) + return mt and type(mt.__call) == 'function' or false + end + return false +end + +---@param value any +---@param depth? integer +---@param seen? table +---@return string +function M.formatValue(value, depth, seen) + depth = depth or 0 + local t = type(value) + if t == 'string' then return ("'%s'"):format(value) end + if t == 'nil' then return 'nil' end + if t == 'boolean' or t == 'number' then return tostring(value) end + if t == 'function' then return '' end + if t == 'table' then + if value._isAsymmetric then return value:toString() end + if depth > 3 then return '{...}' end + seen = seen or {} + if seen[value] then return '' end + seen[value] = true + local parts = {} + local count = 0 + for k, val in pairs(value) do + count = count + 1 + if count > 8 then + parts[#parts + 1] = '...'; break + end + parts[#parts + 1] = ('[%s]=%s'):format(M.formatValue(k, depth + 1, seen), M.formatValue(val, depth + 1, seen)) + end + return '{' .. table.concat(parts, ', ') .. '}' + end + return tostring(value) +end + +---@param a any +---@param b any +---@param seen? table +---@return boolean +function M.deepEqual(a, b, seen) + -- Asymmetric matchers (expect.any, expect.objectContaining, ...) match + -- anywhere in the comparison tree on either side. + if type(a) == 'table' and a._isAsymmetric then return a:match(b) end + if type(b) == 'table' and b._isAsymmetric then return b:match(a) end + + if a == b then return true end + if type(a) ~= type(b) then return false end + if type(a) ~= 'table' then return false end + seen = seen or {} + if seen[a] then return seen[a] == b end + seen[a] = b + local aCount = 0 + for k, v in pairs(a) do + aCount = aCount + 1 + if not M.deepEqual(v, b[k], seen) then return false end + end + local bCount = 0 + for _ in pairs(b) do bCount = bCount + 1 end + return aCount == bCount +end + +---@param node TestNode +---@return string +function M.nodePath(node) + local parts = {} + local cur = node + while cur and cur.parent do + parts[#parts + 1] = cur.name + cur = cur.parent + end + local out = {} + for i = #parts, 1, -1 do out[#out + 1] = parts[i] end + return table.concat(out, ' > ') +end + +---@return TestHooks +function M.newHooks() + return { beforeAll = {}, afterAll = {}, beforeEach = {}, afterEach = {} } +end + +function M.indent(depth) return string.rep(' ', depth) end + +---@param s any +---@return any +function M.stripColors(s) + if type(s) ~= 'string' then return s end + return (s:gsub('%^%d', '')) +end + +return M diff --git a/imports/test/mock.lua b/imports/test/mock.lua new file mode 100644 index 000000000..64ad06dbb --- /dev/null +++ b/imports/test/mock.lua @@ -0,0 +1,92 @@ +-- Mock functions (lib.test.fn) and spies (lib.test.spy). Spies push their +-- restoration entries onto a module-level array; the runner drains it after +-- every test so suites stay isolated. + +local M = { + pendingRestores = {}, +} + +---@generic R +---@param impl? fun(...): R +---@return MockFn +function M.fn(impl) + local mock + mock = setmetatable({ + _isMock = true, + _impl = impl, + _returnValue = nil, + _hasReturnValue = false, + calls = {}, + callCount = 0, + lastCall = nil, + }, { + __call = function(self, ...) + local args = { ... } + self.calls[#self.calls + 1] = args + self.callCount = self.callCount + 1 + self.lastCall = args + if self._hasReturnValue then return self._returnValue end + if self._impl then return self._impl(...) end + end, + }) + + function mock:mockReturnValue(v) + self._returnValue = v + self._hasReturnValue = true + return self + end + + function mock:mockImplementation(f) + self._impl = f + self._hasReturnValue = false + return self + end + + function mock:mockClear() + self.calls = {} + self.callCount = 0 + self.lastCall = nil + return self + end + + function mock:mockReset() + self:mockClear() + self._impl = nil + self._hasReturnValue = false + self._returnValue = nil + return self + end + + return mock +end + +---@param obj table +---@param key string +---@return MockFn +function M.spy(obj, key) + if type(obj) ~= 'table' then error("spy(obj, key): obj must be a table", 2) end + if type(key) ~= 'string' then error("spy(obj, key): key must be a string", 2) end + local original = rawget(obj, key) + if original == nil then + local mt = getmetatable(obj) + if mt and type(mt.__index) == 'table' and mt.__index[key] ~= nil then + lib.print.warn(('spy on inherited method %q, patching directly on object'):format(key)) + original = mt.__index[key] + end + end + local m = M.fn(original) + obj[key] = m + M.pendingRestores[#M.pendingRestores + 1] = { obj = obj, key = key, original = original } + return m +end + +function M.restoreAll() + local list = M.pendingRestores + for i = #list, 1, -1 do + local r = list[i] + r.obj[r.key] = r.original + list[i] = nil + end +end + +return M diff --git a/imports/test/registry.lua b/imports/test/registry.lua new file mode 100644 index 000000000..ac4e184dd --- /dev/null +++ b/imports/test/registry.lua @@ -0,0 +1,102 @@ +-- The suite tree, the current-context stack, and the only-mode flag. +-- State is exposed on the module table (M.root, M.stack, M.hasOnly) so other +-- modules (runner, dsl) can swap or read it. + +local helpers = require '@ox_lib/imports/test/helpers' +local newHooks = helpers.newHooks + +---@class TestHooks +---@field beforeAll function[] +---@field afterAll function[] +---@field beforeEach function[] +---@field afterEach function[] + +---@class TestNode +---@field name string +---@field kind 'suite' | 'test' +---@field body? fun(done?: fun(err?: any)): any +---@field timeout? integer +---@field skipped boolean +---@field only boolean +---@field parent? TestNode +---@field children TestNode[] +---@field hooks TestHooks + +local M = { + ---@type TestNode + root = nil, + ---@type TestNode[] + stack = nil, + ---@type boolean + hasOnly = false, +} + +local function newRoot() + return { + name = '', + kind = 'suite', + skipped = false, + only = false, + children = {}, + hooks = newHooks(), + } +end + +function M.reset() + M.root = newRoot() + M.stack = { M.root } + M.hasOnly = false +end + +M.reset() + +function M.currentSuite() return M.stack[#M.stack] end + +---@param name string +---@param body fun() +---@param flags? { skipped?: boolean, only?: boolean } +function M.pushSuite(name, body, flags) + flags = flags or {} + local suite = { + name = name, + kind = 'suite', + skipped = flags.skipped or false, + only = flags.only or false, + parent = M.currentSuite(), + children = {}, + hooks = newHooks(), + } + M.currentSuite().children[#M.currentSuite().children + 1] = suite + M.stack[#M.stack + 1] = suite + if flags.only then M.hasOnly = true end + local ok, err = pcall(body) + M.stack[#M.stack] = nil + if not ok then + error(("error inside describe('%s'): %s"):format(name, tostring(err)), 0) + end +end + +---@param name string +---@param body fun(done?: fun(err?: any)): any +---@param flags? { skipped?: boolean, only?: boolean, timeout?: integer } +function M.addTest(name, body, flags) + flags = flags or {} + if M.currentSuite().kind == 'test' then + error("cannot nest 'it' inside another 'it'", 3) + end + local test = { + name = name, + kind = 'test', + body = body, + timeout = flags.timeout, + skipped = flags.skipped or false, + only = flags.only or false, + parent = M.currentSuite(), + children = {}, + hooks = newHooks(), + } + M.currentSuite().children[#M.currentSuite().children + 1] = test + if flags.only then M.hasOnly = true end +end + +return M diff --git a/imports/test/reporter.lua b/imports/test/reporter.lua new file mode 100644 index 000000000..6b24d3ef4 --- /dev/null +++ b/imports/test/reporter.lua @@ -0,0 +1,141 @@ +-- Console (default) and JSON reporters. A reporter is any table with optional +-- onRunStart / onSuiteStart / onSuiteEnd / onTestEnd / onRunEnd methods. + +local helpers = require '@ox_lib/imports/test/helpers' +local indent = helpers.indent +local stripColors = helpers.stripColors + +local M = {} + +---@type TestReporter +M.console = { + onRunStart = function(_, rootNode) + print(('^5[ox_lib:test]^7 running %d top-level suite(s)'):format(#rootNode.children)) + end, + onSuiteStart = function(_, suite, depth) + if suite.parent then + print(('%s^6%s^7'):format(indent(depth - 1), suite.name)) + end + end, + onTestEnd = function(_, _, result, depth) + local marker, color + if result.status == 'pass' then + marker, color = '✔', '^2' + elseif result.status == 'fail' then + marker, color = '✘', '^1' + elseif result.status == 'timeout' then + marker, color = '⏱', '^1' + else + marker, color = '○', '^3' + end + local dur = result.duration > 0 and (' ^8(%.1fms)^7'):format(result.duration) or '' + print(('%s%s%s^7 %s%s'):format(indent(depth - 1), color, marker, result.name, dur)) + if result.error then + for line in tostring(result.error):gmatch('[^\n]+') do + print(('%s ^1%s^7'):format(indent(depth - 1), line)) + end + end + end, + onRunEnd = function(_, result) + local function suiteHasFailure(suite) + for _, t in ipairs(suite.tests) do + if t.status == 'fail' or t.status == 'timeout' then return true end + end + for _, c in ipairs(suite.children) do + if suiteHasFailure(c) then return true end + end + return false + end + + local totalSuites = #result.suites + local failedSuites = 0 + for _, s in ipairs(result.suites) do + if suiteHasFailure(s) then failedSuites = failedSuites + 1 end + end + local passedSuites = totalSuites - failedSuites + local totalTests = result.passed + result.failed + result.skipped + result.timedOut + + if #result.failures > 0 then + print('') + print('^1Failures:^7') + for i, f in ipairs(result.failures) do + print((' ^1%d) %s^7'):format(i, f.path)) + if f.error then + for line in tostring(f.error):gmatch('[^\n]+') do + print((' %s'):format(line)) + end + end + end + end + + print('') + print('^5[ox_lib:test]^7 ─── summary ───') + + local suiteParts = {} + if failedSuites > 0 then suiteParts[#suiteParts + 1] = ('^1%d failed^7'):format(failedSuites) end + if passedSuites > 0 then suiteParts[#suiteParts + 1] = ('^2%d passed^7'):format(passedSuites) end + suiteParts[#suiteParts + 1] = ('%d total'):format(totalSuites) + print(('Test Suites: %s'):format(table.concat(suiteParts, ', '))) + + local testParts = {} + if result.failed > 0 then testParts[#testParts + 1] = ('^1%d failed^7'):format(result.failed) end + if result.timedOut > 0 then testParts[#testParts + 1] = ('^1%d timeout^7'):format(result.timedOut) end + if result.skipped > 0 then testParts[#testParts + 1] = ('^3%d skipped^7'):format(result.skipped) end + if result.passed > 0 then testParts[#testParts + 1] = ('^2%d passed^7'):format(result.passed) end + testParts[#testParts + 1] = ('%d total'):format(totalTests) + print(('Tests: %s'):format(table.concat(testParts, ', '))) + + local seconds = result.duration / 1000 + print(('Time: %.3fs'):format(seconds)) + end, +} + +---@type TestReporter +M.json = { + _output = nil, + onRunEnd = function(reporter, result) + local function clean(r) + return { + name = stripColors(r.name), + tests = (function() + local out = {} + for i, t in ipairs(r.tests) do + out[i] = { + name = stripColors(t.name), + path = stripColors(t.path), + status = t.status, + error = t.error and stripColors(t.error) or nil, + duration = t.duration, + } + end + return out + end)(), + children = (function() + local out = {} + for i, c in ipairs(r.children) do out[i] = clean(c) end + return out + end)(), + duration = r.duration, + } + end + local payload = { + passed = result.passed, + failed = result.failed, + skipped = result.skipped, + timedOut = result.timedOut, + duration = result.duration, + suites = (function() + local out = {} + for i, s in ipairs(result.suites) do out[i] = clean(s) end + return out + end)(), + } + reporter._output = json.encode(payload, { indent = true, sort_keys = true }) + print(reporter._output) + end, +} + +-- Empty reporter: every `if reporter.onX then` guard sees nil and skips. +M.silent = {} + +return M diff --git a/imports/test/runner.lua b/imports/test/runner.lua new file mode 100644 index 000000000..41692f47e --- /dev/null +++ b/imports/test/runner.lua @@ -0,0 +1,436 @@ +-- The runner. Walks the registry, races each test against a per-test timeout, +-- collects results, and dispatches reporter callbacks. runIsolated swaps the +-- registry + spy state so tests can verify run-level behavior without +-- disturbing the active run. + +local Registry = require '@ox_lib/imports/test/registry' +local Mock = require '@ox_lib/imports/test/mock' +local Reporter = require '@ox_lib/imports/test/reporter' +local helpers = require '@ox_lib/imports/test/helpers' +local nodePath = helpers.nodePath + +---@alias TestStatus 'pass' | 'fail' | 'skip' | 'timeout' + +---@class TestCaseResult +---@field name string +---@field path string +---@field status TestStatus +---@field error? string +---@field duration number + +---@class SuiteResult +---@field name string +---@field tests TestCaseResult[] +---@field children SuiteResult[] +---@field duration number + +---@class TestResult +---@field passed integer +---@field failed integer +---@field skipped integer +---@field timedOut integer +---@field duration number +---@field suites SuiteResult[] +---@field failures TestCaseResult[] + +---@class TestRunOptions +---@field reporter? 'console' | 'json' | TestReporter +---@field filter? string +---@field timeout? integer +---@field bail? boolean + +---@class TestReporter +---@field onRunStart? fun(self, root: TestNode) +---@field onSuiteStart? fun(self, suite: TestNode, depth: integer) +---@field onSuiteEnd? fun(self, suite: TestNode, depth: integer) +---@field onTestEnd? fun(self, test: TestNode, result: TestCaseResult, depth: integer) +---@field onRunEnd? fun(self, result: TestResult) + +local DEFAULT_TIMEOUT = 5000 + +local function isPromise(v) + return type(v) == 'table' and type(v.next) == 'function' and v.state ~= nil +end + +---@param test TestNode +---@param defaultTimeout integer +---@return boolean ok, string? err +local function runTestBody(test, defaultTimeout) + -- Three completion modes: + -- 1. sync function() ... end done on return + -- 2. promise function() ... return p end done when p settles + -- 3. done() function(done) ... done() end done when done is called + -- nparams == 0 means mode 1 or 2 (no done arg). nparams > 0 means mode 3. + -- `settled` no-ops whichever of {timeout, completion} arrives second. + local p = promise.new() + local settled = false + local timeoutMs = test.timeout or defaultTimeout + local timedOut = false + + local function complete(err) + if settled then return end + settled = true + if err ~= nil then + p:reject(err) + else + p:resolve(true) + end + end + + -- Timeout always fires; `settled` no-ops it if the test finished first. + SetTimeout(timeoutMs, function() + if settled then return end + timedOut = true + complete(('test timed out after %dms'):format(timeoutMs)) + end) + + CreateThread(function() + local nparams = debug.getinfo(test.body, 'u').nparams + local doneCalled = false + local function done(err) + if doneCalled then return end + doneCalled = true + complete(err) + end + + local ok, ret + if nparams > 0 then + ok, ret = pcall(test.body, done) + else + ok, ret = pcall(test.body) + end + + if not ok then return complete(ret) end + if isPromise(ret) then + ret:next(function() complete() end, function(e) complete(e) end) + return + end + -- sync test, no promise returned: resolve. done-callback tests fall + -- through and wait for `done`. + if nparams == 0 then complete() end + end) + + local ok, err = pcall(Citizen.Await, p) + if not ok then + if timedOut then return false, ('TIMEOUT: %s'):format(tostring(err)) end + return false, tostring(err) + end + return true +end + +---@param hooks function[] +---@return boolean ok, string? err +local function runHooks(hooks) + for i = 1, #hooks do + local ok, err = pcall(hooks[i]) + if not ok then return false, tostring(err) end + end + return true +end + +---@param suite TestNode +---@return boolean +local function suiteContainsOnly(suite) + for _, child in ipairs(suite.children) do + if child.only then return true end + if child.kind == 'suite' and suiteContainsOnly(child) then return true end + end + return false +end + +---@param test TestNode +---@param onlyMode boolean +---@return boolean +local function shouldRunTest(test, onlyMode) + -- onlyMode is the global `hasOnly` flag, set during registration when any + -- it.only / describe.only is seen. Safe to read at run time because + -- registration is sync and finishes before run() walks the tree. + if not onlyMode then return true end + if test.only then return true end + -- A test also runs if any *ancestor* suite was marked .only. + local cur = test.parent + while cur do + if cur.only then return true end + cur = cur.parent + end + return false +end + +---@param suite TestNode +---@param onlyMode boolean +---@return boolean +local function shouldVisitSuite(suite, onlyMode) + if not onlyMode then return true end + if suite.only then return true end + return suiteContainsOnly(suite) +end + +---@param node TestNode +---@param filter? string +---@return boolean +local function matchesFilter(node, filter) + if not filter or filter == '' then return true end + local path = nodePath(node):lower() + return path:find(filter:lower(), 1, true) ~= nil +end + +---@param ctx { reporter: TestReporter, result: TestResult, defaultTimeout: integer, filter?: string, bail: boolean, bailed: boolean, onlyMode: boolean } +---@return SuiteResult +local function runSuite(suite, depth, ctx) + if ctx.reporter.onSuiteStart then ctx.reporter:onSuiteStart(suite, depth) end + local suiteResult = { name = suite.name, tests = {}, children = {}, duration = 0 } + local startTime = GetGameTimer() + + if not suite.skipped then + local ok, err = runHooks(suite.hooks.beforeAll) + if not ok then + local function markFail(s) + for _, child in ipairs(s.children) do + if child.kind == 'test' then + local tr = { + name = child.name, + path = nodePath(child), + status = 'fail', + error = ('beforeAll failed: %s'):format(err), + duration = 0, + } + suiteResult.tests[#suiteResult.tests + 1] = tr + ctx.result.failed = ctx.result.failed + 1 + ctx.result.failures[#ctx.result.failures + 1] = tr + if ctx.reporter.onTestEnd then ctx.reporter:onTestEnd(child, tr, depth + 1) end + else + markFail(child) + end + end + end + markFail(suite) + suiteResult.duration = GetGameTimer() - startTime + if ctx.reporter.onSuiteEnd then ctx.reporter:onSuiteEnd(suite, depth) end + return suiteResult + end + end + + local function collectBeforeEach(node) + local out = {} + local chain = {} + local cur = node.parent + while cur do + chain[#chain + 1] = cur + cur = cur.parent + end + for i = #chain, 1, -1 do + for _, h in ipairs(chain[i].hooks.beforeEach) do out[#out + 1] = h end + end + return out + end + + local function collectAfterEach(node) + local out = {} + local cur = node.parent + while cur do + for _, h in ipairs(cur.hooks.afterEach) do out[#out + 1] = h end + cur = cur.parent + end + return out + end + + for _, child in ipairs(suite.children) do + if ctx.bailed then break end + + if child.kind == 'suite' then + if shouldVisitSuite(child, ctx.onlyMode) then + local sub = runSuite(child, depth + 1, ctx) + suiteResult.children[#suiteResult.children + 1] = sub + end + else + local skipNode = suite.skipped or child.skipped or + not shouldRunTest(child, ctx.onlyMode) or + not matchesFilter(child, ctx.filter) + + if skipNode then + local tr = { + name = child.name, + path = nodePath(child), + status = 'skip', + duration = 0, + } + suiteResult.tests[#suiteResult.tests + 1] = tr + ctx.result.skipped = ctx.result.skipped + 1 + if ctx.reporter.onTestEnd then ctx.reporter:onTestEnd(child, tr, depth + 1) end + else + local hookOk, hookErr = runHooks(collectBeforeEach(child)) + local testStart = GetGameTimer() + local ok, err + if hookOk then + ok, err = runTestBody(child, ctx.defaultTimeout) + else + ok = false + err = ('beforeEach failed: %s'):format(hookErr) + end + local duration = GetGameTimer() - testStart + + local afterOk, afterErr = runHooks(collectAfterEach(child)) + Mock.restoreAll() + + local status = 'pass' + local errMsg + if not ok then + if type(err) == 'string' and err:sub(1, 7) == 'TIMEOUT' then + status = 'timeout' + ctx.result.timedOut = ctx.result.timedOut + 1 + else + status = 'fail' + end + errMsg = err + elseif not afterOk then + status = 'fail' + errMsg = ('afterEach failed: %s'):format(afterErr) + end + + local tr = { + name = child.name, + path = nodePath(child), + status = status, + error = errMsg, + duration = duration, + } + suiteResult.tests[#suiteResult.tests + 1] = tr + if status == 'pass' then + ctx.result.passed = ctx.result.passed + 1 + elseif status == 'fail' then + ctx.result.failed = ctx.result.failed + 1 + ctx.result.failures[#ctx.result.failures + 1] = tr + if ctx.bail then ctx.bailed = true end + else -- 'timeout' (already counted in result.timedOut) + ctx.result.failures[#ctx.result.failures + 1] = tr + if ctx.bail then ctx.bailed = true end + end + if ctx.reporter.onTestEnd then ctx.reporter:onTestEnd(child, tr, depth + 1) end + end + end + end + + if not suite.skipped then + local ok, err = runHooks(suite.hooks.afterAll) + if not ok then + local tr = { + name = '', + path = nodePath(suite) .. ' > ', + status = 'fail', + error = err, + duration = 0, + } + suiteResult.tests[#suiteResult.tests + 1] = tr + ctx.result.failed = ctx.result.failed + 1 + ctx.result.failures[#ctx.result.failures + 1] = tr + end + end + + suiteResult.duration = GetGameTimer() - startTime + if ctx.reporter.onSuiteEnd then ctx.reporter:onSuiteEnd(suite, depth) end + return suiteResult +end + +local M = {} + +---@param opts? TestRunOptions +---@return TestResult +function M.run(opts) + opts = opts or {} + local reporterOpt = opts.reporter or 'console' + local reporter + if reporterOpt == 'console' then + reporter = Reporter.console + elseif reporterOpt == 'json' then + reporter = Reporter.json + elseif type(reporterOpt) == 'table' then + reporter = reporterOpt + else + error(("unknown reporter '%s'"):format(tostring(reporterOpt)), 2) + end + + local result = { + passed = 0, + failed = 0, + skipped = 0, + timedOut = 0, + duration = 0, + suites = {}, + failures = {}, + } + local startTime = GetGameTimer() + + if reporter.onRunStart then reporter:onRunStart(Registry.root) end + + local ctx = { + reporter = reporter, + result = result, + defaultTimeout = opts.timeout or DEFAULT_TIMEOUT, + filter = opts.filter, + bail = opts.bail or false, + bailed = false, + onlyMode = Registry.hasOnly, + } + + for _, child in ipairs(Registry.root.children) do + if ctx.bailed then break end + if child.kind == 'suite' and shouldVisitSuite(child, ctx.onlyMode) then + result.suites[#result.suites + 1] = runSuite(child, 1, ctx) + elseif child.kind == 'test' then + -- top-level test (no enclosing describe): wrap in a synthetic suite + local synth = { + name = '', + kind = 'suite', + skipped = false, + only = false, + parent = Registry.root, + children = { child }, + hooks = helpers.newHooks(), + } + child.parent = synth + result.suites[#result.suites + 1] = runSuite(synth, 1, ctx) + end + end + + result.duration = GetGameTimer() - startTime + if reporter.onRunEnd then reporter:onRunEnd(result) end + return result +end + +---Run a self-contained mini-suite against a fresh registry, then restore the +---outer registry. Used to test framework features that affect the whole run +---(it.only, bail, filter, custom reporters) without disturbing the active run. +---@param setupFn fun() +---@param opts? TestRunOptions +---@return TestResult +function M.runIsolated(setupFn, opts) + if type(setupFn) ~= 'function' then error('runIsolated: setupFn must be a function', 2) end + + -- Also swap pendingRestores: inner runs call restoreAll() after each test, + -- which would otherwise wipe spies set up in the outer test. + local savedRoot, savedStack, savedHasOnly = Registry.root, Registry.stack, Registry.hasOnly + local savedRestores = Mock.pendingRestores + Registry.reset() + Mock.pendingRestores = {} + + local result, runErr + local setupOk, setupErr = pcall(setupFn) + if setupOk then + local runOpts = {} + if opts then + for k, v in pairs(opts) do runOpts[k] = v end + end + if runOpts.reporter == nil then runOpts.reporter = Reporter.silent end + local runOk, ret = pcall(M.run, runOpts) + if runOk then result = ret else runErr = ret end + end + + Registry.root, Registry.stack, Registry.hasOnly = savedRoot, savedStack, savedHasOnly + Mock.pendingRestores = savedRestores + + if not setupOk then error('runIsolated setup error: ' .. tostring(setupErr), 2) end + if runErr then error('runIsolated run error: ' .. tostring(runErr), 2) end + return result +end + +return M diff --git a/imports/test/server.lua b/imports/test/server.lua new file mode 100644 index 000000000..48aa5fa8b --- /dev/null +++ b/imports/test/server.lua @@ -0,0 +1,147 @@ +-- lib.test entry point. Composes lib.test from its submodules, registers the +-- runOxTests export in every VM that loads this file, and registers the +-- /oxtest console command inside ox_lib only. + +local helpers = require '@ox_lib/imports/test/helpers' +local Registry = require '@ox_lib/imports/test/registry' +local DSL = require '@ox_lib/imports/test/dsl' +local expect = require '@ox_lib/imports/test/expect' +local Mock = require '@ox_lib/imports/test/mock' +local Discover = require '@ox_lib/imports/test/discover' +local Runner = require '@ox_lib/imports/test/runner' + +---@class TestModule +---@field describe fun(name: string, body: fun()): nil +---@field it OxTestIt +---@field expect TestExpect +---@field fn fun(impl?: fun(...): R): MockFn +---@field spy fun(obj: table, key: string): MockFn +---@field isCallable fun(v: any): boolean +---@field run fun(opts?: TestRunOptions): TestResult +---@field runIsolated fun(setupFn: fun(), opts?: TestRunOptions): TestResult +---@field discover fun(resource?: string): integer +---@field register fun(path: string): nil +---@field reset fun(): nil +---@field beforeEach fun(cb: fun()): nil +---@field afterEach fun(cb: fun()): nil +---@field beforeAll fun(cb: fun()): nil +---@field afterAll fun(cb: fun()): nil + +---@class TestExpect +---@overload fun(actual: T): Expect +---@field any fun(luaType: type): table +---@field anything fun(): table +---@field callable fun(): table +---@field objectContaining fun(subset: table): table +---@field arrayContaining fun(subset: any[]): table + +---@class Expect +---@field never Expect +---@field toBe fun(self, expected: T): nil +---@field toEqual fun(self, expected: any): nil +---@field toBeTruthy fun(self): nil +---@field toBeFalsy fun(self): nil +---@field toBeNil fun(self): nil +---@field toBeGreaterThan fun(self, n: number): nil +---@field toBeLessThan fun(self, n: number): nil +---@field toBeCloseTo fun(self, n: number, decimals?: integer): nil +---@field toBeCallable fun(self): nil +---@field toContain fun(self, needle: any): nil +---@field toHaveLength fun(self, n: integer): nil +---@field toMatch fun(self, pattern: string): nil +---@field toThrow fun(self, pattern?: string): nil +---@field toHaveBeenCalled fun(self): nil +---@field toHaveBeenCalledTimes fun(self, n: integer): nil +---@field toHaveBeenCalledWith fun(self, ...): nil + +---@class MockFn +---@field calls any[][] +---@field callCount integer +---@field lastCall any[]? +---@field mockReturnValue fun(self, v: R): MockFn +---@field mockImplementation fun(self, fn: fun(...): R): MockFn +---@field mockClear fun(self): MockFn +---@field mockReset fun(self): MockFn + +lib.test = { + describe = DSL.describe, + it = DSL.it, + beforeEach = DSL.beforeEach, + afterEach = DSL.afterEach, + beforeAll = DSL.beforeAll, + afterAll = DSL.afterAll, + expect = expect, + fn = Mock.fn, + spy = Mock.spy, + isCallable = helpers.isCallable, + run = Runner.run, + runIsolated = Runner.runIsolated, + discover = Discover.discover, + register = Discover.register, + reset = Registry.reset, +} + +exports('runOxTests', function(filter, reporter) + lib.test.reset() + local count = lib.test.discover() + if count == 0 then return nil end + return lib.test.run({ reporter = reporter or 'console', filter = filter }) +end) + +if GetCurrentResourceName() == 'ox_lib' then + lib.addCommand('oxtest', { + help = 'Run lib.test suites discovered from a resource', + params = { + { name = 'resource', type = 'string', optional = true, help = 'resource to discover tests from' }, + { name = 'filter', type = 'string', optional = true, help = 'substring filter on test paths' }, + }, + }, function(_, args) + local target = args.resource + local reporter = GetConvar('ox:test:reporter', 'console') + + if target then + local state = GetResourceState(target) + if state == 'missing' or state == 'unknown' then + lib.print.error(('resource %q is %s, copy it into resources/ and `ensure` it first'):format(target, state)) + return + end + if state ~= 'started' then + lib.print.warn(('resource %q state is %q (not started), run `ensure %s` first'):format(target, state, target)) + return + end + local ok, err = pcall(function() + return exports[target]:runOxTests(args.filter, reporter) + end) + if not ok then + lib.print.error(('failed to invoke runner in %q: %s'):format(target, tostring(err))) + lib.print.warn(('does %q\'s fxmanifest declare any `ox_test_dir` entries?'):format(target)) + end + return + end + + -- No target: run ox_lib's bundled examples + lib.test.reset() + local examples = { + 'imports/test/examples/passing.lua', + 'imports/test/examples/assertion_errors.lua', + 'imports/test/examples/async.lua', + 'imports/test/examples/timeouts.lua', + 'imports/test/examples/hooks.lua', + 'imports/test/examples/mocks.lua', + 'imports/test/examples/spies.lua', + 'imports/test/examples/parameterized.lua', + 'imports/test/examples/matchers.lua', + 'imports/test/examples/run_options.lua', + } + for i = 1, #examples do + local file = LoadResourceFile(cache.resource, examples[i]) + if file then + local chunk, err = load(file, ('@@%s/%s'):format(cache.resource, examples[i]), 't', _ENV) + if chunk then pcall(chunk) else lib.print.error(err) end + end + end + lib.test.run({ reporter = reporter, filter = args.filter }) + end) +end + +return lib.test diff --git a/init.lua b/init.lua index 11b58cea6..0357a52c9 100644 --- a/init.lua +++ b/init.lua @@ -297,3 +297,9 @@ for i = 1, GetNumResourceMetadata(cache.resource, 'ox_lib') do if type(module) == 'function' then pcall(module) end end end + +-- Auto-load lib.test (server-only) when the resource declares any `ox_test_dir` +-- entries — registers the runOxTests export so /oxtest can invoke it. +if context == 'server' and GetNumResourceMetadata(cache.resource, 'ox_test_dir') > 0 then + loadModule(lib, 'test') +end