From 47c0f7bc1f37472a9ad766808577ec1b8421b8fd Mon Sep 17 00:00:00 2001 From: vocksel Date: Sun, 8 Sep 2024 07:57:49 -0700 Subject: [PATCH] Upgrades (#21) Summary of changes: * Attempted to spruce up the ModuleLoader class' types for consumers * Bumped Foreman tool versions * Imported Lune scripts from flipbook repo * Setup string requires * Updated GitHub workflows to work with Lune scripts * Renamed all .lua files to .luau * Require statements are auto-sorted with stylua * Tests are run with Jest 3 Closes #20 --- .darklua.json | 25 + .github/workflows/.luaurc | 6 - .github/workflows/ci.yml | 48 +- .github/workflows/release.yml | 27 +- .gitignore | 6 +- .luaurc | 6 + .lune/analyze.luau | 29 + .lune/build.luau | 31 + .lune/lib/compile.luau | 33 + .lune/lib/findClientSettings.luau | 23 + .lune/lib/findFilesByPattern.luau | 25 + .lune/lib/parseArgs.luau | 49 ++ .lune/lib/rmrf.luau | 11 + .lune/lib/run.luau | 32 + .lune/lib/setFlags.luau | 10 + .lune/lib/watcher/diffArray.luau | 16 + .lune/lib/watcher/getWatchedFiles.luau | 33 + .lune/lib/watcher/maybeCall.luau | 7 + .lune/lib/watcher/watch.luau | 70 ++ .lune/lint.luau | 43 ++ .lune/test.luau | 12 + .lune/wally-install.luau | 5 + .vscode/settings.json | 13 + analysis.project.json | 15 + build.project.json | 9 + default.project.json | 8 +- foreman.toml | 17 +- project.luau | 20 + scripts/analyze.sh | 9 - scripts/test.sh | 5 - selene-requires.yml | 7 + selene.toml | 5 +- src/{init.lua => ModuleLoader.luau} | 85 ++- src/ModuleLoader.spec.luau | 643 ++++++++++++++++++ src/{bind.lua => bind.luau} | 0 src/bind.spec.lua | 36 - src/bind.spec.luau | 38 ++ ...hrough.lua => createTablePassthrough.luau} | 0 src/createTablePassthrough.spec.lua | 23 - src/createTablePassthrough.spec.luau | 25 + src/{getCallerPath.lua => getCallerPath.luau} | 0 src/{getEnv.lua => getEnv.luau} | 0 src/getEnv.spec.lua | 22 - src/getEnv.spec.luau | 24 + ...xTsRuntime.lua => getRobloxTsRuntime.luau} | 0 src/getRobloxTsRuntime.spec.lua | 26 - src/getRobloxTsRuntime.spec.luau | 28 + src/init.luau | 6 + src/init.spec.lua | 638 ----------------- src/jest.config.luau | 3 + src/types.lua | 13 - stylua.toml | 2 + testez.d.lua | 24 - testez.toml | 78 --- dev.project.json => tests.project.json | 4 +- tests/ClientAppSettings.json | 3 + tests/init.server.lua | 13 - tests/run-tests.luau | 21 + wally.toml | 9 +- 59 files changed, 1445 insertions(+), 974 deletions(-) create mode 100644 .darklua.json delete mode 100644 .github/workflows/.luaurc create mode 100644 .luaurc create mode 100644 .lune/analyze.luau create mode 100644 .lune/build.luau create mode 100644 .lune/lib/compile.luau create mode 100644 .lune/lib/findClientSettings.luau create mode 100644 .lune/lib/findFilesByPattern.luau create mode 100644 .lune/lib/parseArgs.luau create mode 100644 .lune/lib/rmrf.luau create mode 100644 .lune/lib/run.luau create mode 100644 .lune/lib/setFlags.luau create mode 100644 .lune/lib/watcher/diffArray.luau create mode 100644 .lune/lib/watcher/getWatchedFiles.luau create mode 100644 .lune/lib/watcher/maybeCall.luau create mode 100644 .lune/lib/watcher/watch.luau create mode 100644 .lune/lint.luau create mode 100644 .lune/test.luau create mode 100644 .lune/wally-install.luau create mode 100644 .vscode/settings.json create mode 100644 analysis.project.json create mode 100644 build.project.json create mode 100644 project.luau delete mode 100755 scripts/analyze.sh delete mode 100755 scripts/test.sh create mode 100644 selene-requires.yml rename src/{init.lua => ModuleLoader.luau} (80%) create mode 100644 src/ModuleLoader.spec.luau rename src/{bind.lua => bind.luau} (100%) delete mode 100644 src/bind.spec.lua create mode 100644 src/bind.spec.luau rename src/{createTablePassthrough.lua => createTablePassthrough.luau} (100%) delete mode 100644 src/createTablePassthrough.spec.lua create mode 100644 src/createTablePassthrough.spec.luau rename src/{getCallerPath.lua => getCallerPath.luau} (100%) rename src/{getEnv.lua => getEnv.luau} (100%) delete mode 100644 src/getEnv.spec.lua create mode 100644 src/getEnv.spec.luau rename src/{getRobloxTsRuntime.lua => getRobloxTsRuntime.luau} (100%) delete mode 100644 src/getRobloxTsRuntime.spec.lua create mode 100644 src/getRobloxTsRuntime.spec.luau create mode 100644 src/init.luau delete mode 100644 src/init.spec.lua create mode 100644 src/jest.config.luau delete mode 100644 src/types.lua create mode 100644 stylua.toml delete mode 100644 testez.d.lua delete mode 100644 testez.toml rename dev.project.json => tests.project.json (82%) create mode 100644 tests/ClientAppSettings.json delete mode 100644 tests/init.server.lua create mode 100644 tests/run-tests.luau diff --git a/.darklua.json b/.darklua.json new file mode 100644 index 0000000..183d337 --- /dev/null +++ b/.darklua.json @@ -0,0 +1,25 @@ +{ + "process": [ + { + "rule": "convert_require", + "current": { + "name": "path", + "sources": { + "@pkg": "./Packages", + "@root": "./src" + } + }, + "target": { + "name": "roblox", + "rojo_sourcemap": "./sourcemap-darklua.json", + "indexing_style": "property" + } + }, + "compute_expression", + "remove_unused_if_branch", + "remove_unused_while", + "filter_after_early_return", + "remove_nil_declaration", + "remove_empty_do" + ] +} \ No newline at end of file diff --git a/.github/workflows/.luaurc b/.github/workflows/.luaurc deleted file mode 100644 index 1fad7ca..0000000 --- a/.github/workflows/.luaurc +++ /dev/null @@ -1,6 +0,0 @@ -{ - "languageMode": "nocheck", - "lint": { - "*": true - } -} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9653f1f..3bac0f5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -16,44 +16,40 @@ jobs: with: token: ${{ secrets.GITHUB_TOKEN }} - - name: Lint - run: | - selene generate-roblox-std - selene src/ + - name: Install packages + run: lune run wally-install - - name: Format - run: stylua --check src/ + - name: Lint + run: lune run lint - - name: Install dependencies - run: wally install + - name: Get model file name + run: | + name=$(jq -r .name default.project.json) + sha=${GITHUB_SHA:0:7} + echo "MODEL_FILE=$name-$sha.rbxm" >> $GITHUB_ENV - name: Build - run: rojo build -o build.rbxm + run: lune run build -- --target prod --output ${{ env.MODEL_FILE }} + + - uses: actions/upload-artifact@v3 + with: + name: ${{ env.MODEL_FILE }} + path: ${{ env.MODEL_FILE }} analyze: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v3 - uses: Roblox/setup-foreman@v1 with: token: ${{ secrets.GITHUB_TOKEN }} - - name: Install dependencies - run: wally install - - - name: Download global Roblox types - shell: bash - run: curl -s -O https://raw.githubusercontent.com/JohnnyMorganz/luau-lsp/master/scripts/globalTypes.d.lua - - - name: Generate sourcemap for LSP - shell: bash - run: rojo sourcemap dev.project.json -o sourcemap.json + - name: Setup Lune typedefs + run: lune setup - - name: Ignore packages in analysis - shell: bash - run: mv .github/workflows/.luaurc Packages + - name: Install packages + run: lune run wally-install - - name: Analyze - shell: bash - run: luau-lsp analyze --sourcemap=sourcemap.json --defs=globalTypes.d.lua --defs=testez.d.lua --formatter=gnu src/ + - name: Run Luau analysis + run: lune run analyze diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6bf4834..8398432 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,33 +1,34 @@ name: Release on: + pull_request: release: types: [published] -env: - OUTPUT_NAME: "ModuleLoader.rbxm" - jobs: publish: runs-on: ubuntu-latest steps: - - name: Checkout - uses: actions/checkout@v2 + - uses: actions/checkout@v3 - - name: Install toolchain - uses: Roblox/setup-foreman@v1 + - uses: Roblox/setup-foreman@v1 with: token: ${{ secrets.GITHUB_TOKEN }} - - name: Remove spec files - run: rm -rf **/*.spec.lua + - name: Get model file name + run: | + name=$(jq -r .name default.project.json) + echo "MODEL_FILE=$name.rbxm" >> $GITHUB_ENV + + - name: Install packages + run: lune run wally-install - name: Build - run: rojo build -o ${{ env.OUTPUT_NAME }} + run: lune run build -- --target prod --output ${{ env.MODEL_FILE }} - - name: Add model file to release - uses: softprops/action-gh-release@v1 + - uses: softprops/action-gh-release@v1 + if: ${{ github.event.release }} with: - files: ${{ env.OUTPUT_NAME }} + files: ${{ env.MODEL_FILE }} env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.gitignore b/.gitignore index 6ae64d4..22d1539 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,11 @@ # Rojo /*.rbxl* /*.rbxm* -sourcemap.json + +# Luau +dist/ +sourcemap*.json +globalTypes.d.luau # Selene /roblox.toml diff --git a/.luaurc b/.luaurc new file mode 100644 index 0000000..d45c566 --- /dev/null +++ b/.luaurc @@ -0,0 +1,6 @@ +{ + "aliases": { + "pkg": "./Packages", + "root": "./src" + } +} \ No newline at end of file diff --git a/.lune/analyze.luau b/.lune/analyze.luau new file mode 100644 index 0000000..35c72ec --- /dev/null +++ b/.lune/analyze.luau @@ -0,0 +1,29 @@ +local project = require("../project") +local run = require("./lib/run") + +local globalDefsPath = "globalTypes.d.luau" + +run("curl", { + "-s", + "-o", + globalDefsPath, + "-O", + "https://raw.githubusercontent.com/JohnnyMorganz/luau-lsp/master/scripts/globalTypes.d.lua", +}) + +run("rojo", { + "sourcemap", + project.ROJO_ANALYSIS_PROJECT, + "-o", + project.SOURCEMAP_PATH, +}) + +run("luau-lsp", { + "analyze", + `--sourcemap={project.SOURCEMAP_PATH}`, + `--defs={globalDefsPath}`, + "--settings=./.vscode/settings.json", + "--ignore=**/_Index/**", + project.SOURCE_PATH, + project.LUNE_SCRIPTS_PATH, +}) diff --git a/.lune/build.luau b/.lune/build.luau new file mode 100644 index 0000000..32cba50 --- /dev/null +++ b/.lune/build.luau @@ -0,0 +1,31 @@ +local compile = require("./lib/compile") +local parseArgs = require("./lib/parseArgs") +local process = require("@lune/process") +local project = require("../project") +local run = require("./lib/run") +local watch = require("./lib/watcher/watch") + +local args = parseArgs(process.args) + +local target = if args.target then args.target else "prod" + +local function build() + run("rm", { "-rf", project.BUILD_PATH }) + compile(target) + + if target == "prod" then + run("rm", { "-rf", `{project.BUILD_PATH}/**/*.spec.luau` }) + end +end + +build() + +if args.watch then + watch({ + filePatterns = { + "src/.*%.luau", + "example/.*%.luau", + }, + onChanged = build, + }) +end diff --git a/.lune/lib/compile.luau b/.lune/lib/compile.luau new file mode 100644 index 0000000..7309e61 --- /dev/null +++ b/.lune/lib/compile.luau @@ -0,0 +1,33 @@ +local fs = require("@lune/fs") + +local project = require("../../project") +local run = require("./run") + +type Target = "prod" | "dev" + +local function compile(target: Target) + fs.writeDir(project.BUILD_PATH) + + run("rojo", { + "sourcemap", + project.ROJO_BUILD_PROJECT, + "-o", + project.DARKLUA_SOURCEMAP_PATH, + }) + + run("darklua", { + "process", + project.SOURCE_PATH, + project.BUILD_PATH, + }) + + if target == "dev" then + run("darklua", { + "process", + "example", + `{project.BUILD_PATH}/Example`, + }) + end +end + +return compile diff --git a/.lune/lib/findClientSettings.luau b/.lune/lib/findClientSettings.luau new file mode 100644 index 0000000..ebc36bb --- /dev/null +++ b/.lune/lib/findClientSettings.luau @@ -0,0 +1,23 @@ +local run = require("./run") + +local function findClientSettings(os: string) + if os == "macos" then + return "/Applications/RobloxStudio.app/Contents/MacOS/ClientSettings" + elseif os == "windows" then + local robloxStudioPath = run("find", { + "$LOCALAPPDATA/Roblox/Versions", + "-name", + "RobloxStudioBeta.exe", + }) + + local dir = run("dirname", { + robloxStudioPath, + }) + + return `{dir}/ClientSettings` + else + return nil + end +end + +return findClientSettings diff --git a/.lune/lib/findFilesByPattern.luau b/.lune/lib/findFilesByPattern.luau new file mode 100644 index 0000000..af42bdc --- /dev/null +++ b/.lune/lib/findFilesByPattern.luau @@ -0,0 +1,25 @@ +local fs = require("@lune/fs") + +local function findFilesByPattern(rootPath: string, pattern: string) + local matches = {} + + local function search(path: string) + for _, file in fs.readDir(path) do + local filePath = `{path}/{file}` + + if fs.isDir(filePath) then + search(filePath) + else + if filePath:match(pattern) then + table.insert(matches, filePath) + end + end + end + end + + search(rootPath) + + return matches +end + +return findFilesByPattern diff --git a/.lune/lib/parseArgs.luau b/.lune/lib/parseArgs.luau new file mode 100644 index 0000000..97359ac --- /dev/null +++ b/.lune/lib/parseArgs.luau @@ -0,0 +1,49 @@ +local FLAG_PATTERN = "%-%-(%w+)" +local FLAG_ALL_IN_ONE_PATTERN = `{FLAG_PATTERN}=(%w+)` + +local function parseArgs(args: { string }): { [string]: string | boolean | number } + local parsedArgs: { [string]: string } = {} + local skipNextToken = false + + for index, token in args do + -- When `--foo bar` is used, these are both individual tokens that we + -- process at the same time. In those cases, we need to skip the next + -- token (`bar`) since it has already been picked up as a flag value + if skipNextToken then + skipNextToken = false + continue + end + + -- handle `--foo=bar` pattern + local flagName, flagValue = token:match(FLAG_ALL_IN_ONE_PATTERN) + if flagName and flagValue then + parsedArgs[flagName] = flagValue + continue + end + + flagName = token:match(FLAG_PATTERN) + if flagName then + local nextToken = args[index + 1] + + if nextToken then + -- When processing `--foo` in `--foo --bar` treat it like a boolean + if nextToken:match(FLAG_PATTERN) then + flagValue = true + else + flagValue = nextToken + skipNextToken = true + end + else + flagValue = true + end + + parsedArgs[flagName] = flagValue + else + error(`something went wrong: {token}`) + end + end + + return parsedArgs +end + +return parseArgs diff --git a/.lune/lib/rmrf.luau b/.lune/lib/rmrf.luau new file mode 100644 index 0000000..4aeb3cd --- /dev/null +++ b/.lune/lib/rmrf.luau @@ -0,0 +1,11 @@ +local fs = require("@lune/fs") + +local function rmrf(path: string) + if fs.isDir(path) then + fs.removeDir(path) + else + fs.removeFile(path) + end +end + +return rmrf diff --git a/.lune/lib/run.luau b/.lune/lib/run.luau new file mode 100644 index 0000000..357c3e7 --- /dev/null +++ b/.lune/lib/run.luau @@ -0,0 +1,32 @@ +local process = require("@lune/process") +local stdio = require("@lune/stdio") + +type Options = { + cwd: string?, + env: { [string]: string }?, +} + +local function run(program: string, params: { string }, options: Options?) + stdio.write(stdio.style("bold")) + print(`> {program} {table.concat(params, " ")}`) + stdio.write(stdio.style("reset")) + + local result = process.spawn(program, params, { + stdio = "inherit", + shell = true, + cwd = if options then options.cwd else nil, + env = if options then options.env else nil, + }) + + if result.code > 0 then + process.exit(result.code) + end + + local output = if result.ok then result.stdout else result.stderr + + output = output:gsub("\n$", "") + + return output +end + +return run diff --git a/.lune/lib/setFlags.luau b/.lune/lib/setFlags.luau new file mode 100644 index 0000000..3942fa7 --- /dev/null +++ b/.lune/lib/setFlags.luau @@ -0,0 +1,10 @@ +local findClientSettings = require("./findClientSettings") +local run = require("./run") + +local function setFlags(os: string) + local clientSettings = findClientSettings(os) + run("mkdir", { "-p", clientSettings }) + run("cp", { "-R", "tests/ClientAppSettings.json", clientSettings }) +end + +return setFlags diff --git a/.lune/lib/watcher/diffArray.luau b/.lune/lib/watcher/diffArray.luau new file mode 100644 index 0000000..99c5690 --- /dev/null +++ b/.lune/lib/watcher/diffArray.luau @@ -0,0 +1,16 @@ +local function diffArray(base: { T }, compare: { T }): { T } + local diff = {} + for _, value in compare do + if not table.find(base, value) then + table.insert(diff, value) + end + end + for _, value in base do + if not table.find(compare, value) then + table.insert(diff, value) + end + end + return diff +end + +return diffArray diff --git a/.lune/lib/watcher/getWatchedFiles.luau b/.lune/lib/watcher/getWatchedFiles.luau new file mode 100644 index 0000000..74907c3 --- /dev/null +++ b/.lune/lib/watcher/getWatchedFiles.luau @@ -0,0 +1,33 @@ +local fs = require("@lune/fs") + +local function getWatchedFiles(filePatterns: { string }): { string } + local files = {} + + local function traverse(rootDir: string, filePattern: string) + for _, file in fs.readDir(rootDir) do + local filePath = `{rootDir}/{file}` + if fs.isDir(filePath) then + traverse(filePath, filePattern) + else + if filePath:match(filePattern) then + table.insert(files, filePath) + end + end + end + end + + for _, filePattern in filePatterns do + local rootDir = filePattern:split("/")[1] + + assert( + rootDir and fs.isDir(rootDir), + `first part of file pattern must be a directory ({rootDir} is not a directory)` + ) + + traverse(rootDir, filePattern) + end + + return files +end + +return getWatchedFiles diff --git a/.lune/lib/watcher/maybeCall.luau b/.lune/lib/watcher/maybeCall.luau new file mode 100644 index 0000000..c2659ad --- /dev/null +++ b/.lune/lib/watcher/maybeCall.luau @@ -0,0 +1,7 @@ +local function maybeCall(callback: ((...T) -> ())?, ...) + if callback then + pcall(callback, ...) + end +end + +return maybeCall diff --git a/.lune/lib/watcher/watch.luau b/.lune/lib/watcher/watch.luau new file mode 100644 index 0000000..6e7dcab --- /dev/null +++ b/.lune/lib/watcher/watch.luau @@ -0,0 +1,70 @@ +local fs = require("@lune/fs") +local stdio = require("@lune/stdio") +local task = require("@lune/task") + +local diffArray = require("./diffArray") +local getWatchedFiles = require("./getWatchedFiles") +local maybeCall = require("./maybeCall") + +type FileChanges = { string } + +type Options = { + filePatterns: { string }, + onAdded: ((changedFiles: FileChanges) -> ())?, + onRemoved: ((changedFiles: FileChanges) -> ())?, + onChanged: ((changedFiles: FileChanges) -> ())?, +} + +local function watch(options: Options) + local prevWatchedFileMetadata: { [string]: string } = {} + local watchedFiles = getWatchedFiles(options.filePatterns) + local prevWatchedFiles + + print("watching files:") + stdio.write(stdio.style("dim")) + for _, watchedFile in watchedFiles do + print(`> {watchedFile}`) + end + stdio.write(stdio.style("reset")) + print("listening for file changes...") + + -- FIXME: Ctrl+C doesn't cancel the loop. Is this a Lune bug or a Foreman bug? + while true do + local changedFiles: FileChanges = {} + + if prevWatchedFiles and #watchedFiles ~= #prevWatchedFiles then + changedFiles = diffArray(prevWatchedFiles, watchedFiles) + + if #watchedFiles > #prevWatchedFiles then + maybeCall(options.onAdded, changedFiles) + elseif #watchedFiles < #prevWatchedFiles then + maybeCall(options.onRemoved, changedFiles) + + for _, filePath in changedFiles do + prevWatchedFileMetadata[filePath] = nil + end + end + end + + for _, watchedFile in watchedFiles do + local metadata = fs.metadata(watchedFile) + + local prevMetadata = prevWatchedFileMetadata[watchedFile] + if prevMetadata and metadata.modifiedAt > prevMetadata.modifiedAt then + table.insert(changedFiles, watchedFile) + end + + prevWatchedFileMetadata[watchedFile] = metadata + end + + if #changedFiles > 0 then + maybeCall(options.onChanged, changedFiles) + end + + prevWatchedFiles = watchedFiles + task.wait(1) + watchedFiles = getWatchedFiles(options.filePatterns) + end +end + +return watch diff --git a/.lune/lint.luau b/.lune/lint.luau new file mode 100644 index 0000000..24330d6 --- /dev/null +++ b/.lune/lint.luau @@ -0,0 +1,43 @@ +local fs = require("@lune/fs") +local process = require("@lune/process") + +local project = require("../project") +local run = require("./lib/run") + +local function findLuaFiles() + local matches = {} + + local function search(path: string) + for _, file in fs.readDir(path) do + local filePath = `{path}/{file}` + + if fs.isDir(filePath) then + search(filePath) + else + if filePath:match(".lua$") then + table.insert(matches, filePath) + end + end + end + end + + for _, folder in project.FOLDERS_TO_LINT do + search(folder) + end + + return matches +end + +run("selene", project.FOLDERS_TO_LINT) + +run("stylua", { + "--check", + table.unpack(project.FOLDERS_TO_LINT), +}) + +local files = findLuaFiles() +if #files > 0 then + print("[err] the following file(s) are using the '.lua' extension. Please change to '.luau' and try again") + print(`{table.concat(files, "\n")}`) + process.exit(1) +end diff --git a/.lune/test.luau b/.lune/test.luau new file mode 100644 index 0000000..04eda15 --- /dev/null +++ b/.lune/test.luau @@ -0,0 +1,12 @@ +local process = require("@lune/process") + +local compile = require("./lib/compile") +local project = require("../project") +local run = require("./lib/run") +local setFlags = require("./lib/setFlags") + +setFlags(process.os) +compile("dev") + +run("rojo", { "build", project.ROJO_TESTS_PROJECT, "-o", "test-place.rbxl" }) +run("run-in-roblox", { "--place", "test-place.rbxl", "--script", "tests/run-tests.luau" }) diff --git a/.lune/wally-install.luau b/.lune/wally-install.luau new file mode 100644 index 0000000..de9144d --- /dev/null +++ b/.lune/wally-install.luau @@ -0,0 +1,5 @@ +local run = require("./lib/run") + +run("wally", { "install" }) +run("rojo", { "sourcemap", "build.project.json", "-o", "sourcemap.json" }) +run("wally-package-types", { "--sourcemap", "sourcemap.json", "Packages" }) diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..06b0191 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,13 @@ +{ + "luau-lsp.sourcemap.rojoProjectFile": "analysis.project.json", + "luau-lsp.require.mode": "relativeToFile", + "luau-lsp.ignoreGlobs": [ + "**/_Index/**", + "**/build/**" + ], + "luau-lsp.require.directoryAliases": { + "@pkg": "./Packages", + "@root": "./src", + "@lune/": "~/.lune/.typedefs/0.8.7" + } +} \ No newline at end of file diff --git a/analysis.project.json b/analysis.project.json new file mode 100644 index 0000000..2b16671 --- /dev/null +++ b/analysis.project.json @@ -0,0 +1,15 @@ +{ + "name": "Analysis", + "tree": { + "$className": "Folder", + "Lune": { + "$path": ".lune" + }, + "TestRunner": { + "$path": "tests" + }, + "ModuleLoader": { + "$path": "build.project.json" + } + } +} \ No newline at end of file diff --git a/build.project.json b/build.project.json new file mode 100644 index 0000000..abcd43b --- /dev/null +++ b/build.project.json @@ -0,0 +1,9 @@ +{ + "name": "Packages", + "tree": { + "$path": "Packages", + "ModuleLoader": { + "$path": "src" + } + } +} \ No newline at end of file diff --git a/default.project.json b/default.project.json index 91916b5..5865625 100644 --- a/default.project.json +++ b/default.project.json @@ -1,6 +1,6 @@ { - "name": "module-loader", - "tree": { - "$path": "src" - } + "name": "ModuleLoader", + "tree": { + "$path": "dist" + } } \ No newline at end of file diff --git a/foreman.toml b/foreman.toml index dd6c40f..2de8a96 100644 --- a/foreman.toml +++ b/foreman.toml @@ -1,8 +1,11 @@ [tools] -luau-lsp = { source = "JohnnyMorganz/luau-lsp", version = "*" } -moonwave-extractor = { source = "UpliftGames/moonwave", version = "0.3" } -rojo = { source = "rojo-rbx/rojo", version = "7" } -run-in-roblox = { source = "rojo-rbx/run-in-roblox", version = "0.3" } -selene = { source = "kampfkarren/selene", version = "*" } -stylua = { source = "JohnnyMorganz/StyLua", version = "*" } -wally = { source = "UpliftGames/wally", version = "0.3" } +darklua = { source = "seaofvoices/darklua", version = "0.13.1" } +luau-lsp = { source = "JohnnyMorganz/luau-lsp", version = "1.32.3" } +lune = { source = "lune-org/lune", version = "0.8.7" } +moonwave-extractor = { source = "evaera/moonwave", version = "1.1.3" } +rojo = { source = "rojo-rbx/rojo", version = "7.4.3" } +run-in-roblox = { source = "rojo-rbx/run-in-roblox", version = "0.3.0" } +selene = { source = "Kampfkarren/selene", version = "0.27.1" } +stylua = { source = "JohnnyMorganz/StyLua", version = "0.20.0" } +wally = { source = "UpliftGames/wally", version = "0.3.2" } +wally-package-types = { source = "JohnnyMorganz/wally-package-types", version = "1.3.2" } diff --git a/project.luau b/project.luau new file mode 100644 index 0000000..02d6a07 --- /dev/null +++ b/project.luau @@ -0,0 +1,20 @@ +-- All paths are relative to the root of the repo + +return { + SOURCE_PATH = "src", + BUILD_PATH = "dist", + PACKAGES_PATH = "Packages", + LUNE_SCRIPTS_PATH = ".lune", + + SOURCEMAP_PATH = "sourcemap.json", + DARKLUA_SOURCEMAP_PATH = "sourcemap-darklua.json", + + ROJO_ANALYSIS_PROJECT = "analysis.project.json", + ROJO_BUILD_PROJECT = "build.project.json", + ROJO_TESTS_PROJECT = "tests.project.json", + + FOLDERS_TO_LINT = { + "src", + ".lune", + }, +} diff --git a/scripts/analyze.sh b/scripts/analyze.sh deleted file mode 100755 index d8f22d5..0000000 --- a/scripts/analyze.sh +++ /dev/null @@ -1,9 +0,0 @@ -#!/usr/bin/env bash - -curl -s -O https://raw.githubusercontent.com/JohnnyMorganz/luau-lsp/master/scripts/globalTypes.d.lua - -rojo sourcemap dev.project.json -o sourcemap.json - -luau-lsp analyze --sourcemap=sourcemap.json --defs=globalTypes.d.lua --defs=testez.d.lua --ignore=**/_Index/** src/ - -rm globalTypes.d.lua \ No newline at end of file diff --git a/scripts/test.sh b/scripts/test.sh deleted file mode 100755 index 0968227..0000000 --- a/scripts/test.sh +++ /dev/null @@ -1,5 +0,0 @@ -#!/usr/bin/env bash - -rojo build dev.project.json -o studio-tests.rbxl -run-in-roblox --place studio-tests.rbxl --script tests/init.server.lua -pkill -n RobloxStudio \ No newline at end of file diff --git a/selene-requires.yml b/selene-requires.yml new file mode 100644 index 0000000..95cabdf --- /dev/null +++ b/selene-requires.yml @@ -0,0 +1,7 @@ +base: roblox +name: selene_defs +globals: + # override Roblox require style with string requires + require: + args: + - type: string diff --git a/selene.toml b/selene.toml index 49fb47e..3c932b7 100644 --- a/selene.toml +++ b/selene.toml @@ -1 +1,4 @@ -std = "roblox+testez" +std = "selene-requires" + +[lints] +global_usage = "allow" diff --git a/src/init.lua b/src/ModuleLoader.luau similarity index 80% rename from src/init.lua rename to src/ModuleLoader.luau index 33bfc26..62b978a 100644 --- a/src/init.lua +++ b/src/ModuleLoader.luau @@ -1,14 +1,61 @@ -local Janitor = require(script.Parent.Janitor) -local GoodSignal = require(script.Parent.GoodSignal) -local bind = require(script.bind) -local getCallerPath = require(script.getCallerPath) -local getEnv = require(script.getEnv) -local createTablePassthrough = require(script.createTablePassthrough) -local getRobloxTsRuntime = require(script.getRobloxTsRuntime) -local types = require(script.types) - -type ModuleConsumers = types.ModuleConsumers -type ModuleGlobals = types.ModuleGlobals +local GoodSignal = require("@pkg/GoodSignal") +local Janitor = require("@pkg/Janitor") + +local bind = require("./bind") +local createTablePassthrough = require("./createTablePassthrough") +local getCallerPath = require("./getCallerPath") +local getEnv = require("./getEnv") +local getRobloxTsRuntime = require("./getRobloxTsRuntime") + +type CachedModuleResult = any + +type ModuleConsumers = { + [string]: boolean, +} + +-- Each module gets its own global table that it can modify via _G. This makes +-- it easy to clear out a module and the globals it defines without impacting +-- other modules. A module's function environment has all globals merged +-- together on _G +type ModuleGlobals = { + [any]: any, +} + +type CachedModule = { + module: ModuleScript, + isLoaded: boolean, + result: CachedModuleResult, + consumers: ModuleConsumers, + globals: ModuleGlobals, +} + +type ModuleLoaderProps = { + _cache: { [string]: CachedModule }, + _loadstring: typeof(loadstring), + _debugInfo: typeof(debug.info), + _janitors: { [string]: typeof(Janitor.new()) }, + _globals: { [any]: any }, + + loadedModuleChanged: RBXScriptSignal, +} + +type ModuleLoaderImpl = { + __index: ModuleLoaderImpl, + + new: () -> ModuleLoader, + + require: (self: ModuleLoader, module: ModuleScript) -> any, + cache: (self: ModuleLoader, module: ModuleScript, result: any) -> (), + clearModule: (self: ModuleLoader, module: ModuleScript) -> (), + clear: (self: ModuleLoader) -> (), + + _loadCachedModule: (self: ModuleLoader, module: ModuleScript) -> CachedModuleResult, + _getSource: (self: ModuleLoader, module: ModuleScript) -> string, + _trackChanges: (self: ModuleLoader, module: ModuleScript) -> (), + _getConsumers: (self: ModuleLoader, module: ModuleScript) -> { ModuleScript }, +} + +export type ModuleLoader = typeof(setmetatable({} :: ModuleLoaderProps, {} :: ModuleLoaderImpl)) --[=[ ModuleScript loader that bypasses Roblox's require cache. @@ -21,17 +68,9 @@ type ModuleGlobals = types.ModuleGlobals @class ModuleLoader ]=] -local ModuleLoader = {} +local ModuleLoader = {} :: ModuleLoaderImpl ModuleLoader.__index = ModuleLoader -export type CachedModule = { - module: ModuleScript, - isLoaded: boolean, - result: any, - consumers: ModuleConsumers, - globals: ModuleGlobals, -} - --[=[ Constructs a new ModuleLoader instance. ]=] @@ -93,12 +132,12 @@ end @private ]=] -function ModuleLoader:_getSource(module: ModuleScript): any? +function ModuleLoader:_getSource(module: ModuleScript): string local success, result = pcall(function() return module.Source end) - return if success then result else nil + return if success then result else "" end --[=[ @@ -308,6 +347,4 @@ function ModuleLoader:clear() self._janitors = {} end -export type Class = typeof(ModuleLoader.new()) - return ModuleLoader diff --git a/src/ModuleLoader.spec.luau b/src/ModuleLoader.spec.luau new file mode 100644 index 0000000..ef0a974 --- /dev/null +++ b/src/ModuleLoader.spec.luau @@ -0,0 +1,643 @@ +local ReplicatedStorage = game:GetService("ReplicatedStorage") + +local JestGlobals = require("@pkg/JestGlobals") +local it = JestGlobals.it +local expect = JestGlobals.expect +local describe = JestGlobals.describe +local beforeEach = JestGlobals.beforeEach +local afterEach = JestGlobals.afterEach + +local ModuleLoader = require("./ModuleLoader") + +local function countDict(dict: { [string]: any }) + local count = 0 + for _ in pairs(dict) do + count += 1 + end + return count +end + +type ModuleTestTree = { + [string]: string | ModuleTestTree, +} +local testNumber = 0 +local function createModuleTest(tree: ModuleTestTree, parent: Instance?) + testNumber += 1 + + local root = Instance.new("Folder") + root.Name = "ModuleTest" .. testNumber + + parent = if parent then parent else root + + for name, sourceOrDescendants in tree do + if typeof(sourceOrDescendants) == "table" then + createModuleTest(sourceOrDescendants, parent) + else + local module = Instance.new("ModuleScript") + module.Name = name + module.Source = sourceOrDescendants + module.Parent = parent + end + end + + root.Parent = game + + return root +end + +local mockModuleSource = {} +local loader: ModuleLoader.ModuleLoader +local tree + +beforeEach(function() + loader = ModuleLoader.new() +end) + +afterEach(function() + loader:clear() + + if tree then + tree:Destroy() + end +end) + +describe("_getSource", function() + -- This test doesn't supply much value. Essentially, the "Source" + -- property requires elevated permissions, so we need the _getSource + -- method so that that if tests are being run from within a normal + -- script context that an error will not be produced. + it("should return the Source property if it can be indexed", function() + local mockModuleInstance = Instance.new("ModuleScript") + + local canIndex = pcall(function() + return mockModuleInstance.Source + end) + + local source = loader:_getSource(mockModuleInstance) + + if canIndex then + expect(source).toBeDefined() + else + expect(source).toBeUndefined() + end + end) +end) + +describe("_trackChanges", function() + it("should create a Janitor instance if it doesn't exist", function() + local mockModuleInstance = Instance.new("ModuleScript") + + expect(loader._janitors[mockModuleInstance.Name]).toBeUndefined() + + loader:_trackChanges(mockModuleInstance) + + expect(loader._janitors[mockModuleInstance.Name]).toBeDefined() + end) + + it("should reuse the same Janitor instance for future calls", function() + local mockModuleInstance = Instance.new("ModuleScript") + + loader:_trackChanges(mockModuleInstance) + + local janitor = loader._janitors[mockModuleInstance.Name] + + loader:_trackChanges(mockModuleInstance) + + expect(loader._janitors[mockModuleInstance.Name]).toBe(janitor) + end) +end) + +describe("loadedModuleChanged", function() + it("should fire when a required module has its ancestry changed", function() + local mockModuleInstance = Instance.new("ModuleScript") + + local wasFired = false + + -- Parent the ModuleScript somewhere in the DataModel so we can + -- listen for AncestryChanged. + mockModuleInstance.Parent = game + + loader.loadedModuleChanged:Connect(function(other: ModuleScript) + if other == mockModuleInstance then + wasFired = true + end + end) + + -- Require the module so that events get setup + loader:require(mockModuleInstance) + + -- Trigger AncestryChanged to fire + mockModuleInstance.Parent = nil + + expect(wasFired).toBe(true) + end) + + it("should fire when a required module has its Source property change", function() + local mockModuleInstance = Instance.new("ModuleScript") + + local wasFired = false + loader.loadedModuleChanged:Connect(function(other: ModuleScript) + if other == mockModuleInstance then + wasFired = true + end + end) + + -- Require the module so that events get setup + loader:require(mockModuleInstance) + + mockModuleInstance.Source = "Something different" + + expect(wasFired).toBe(true) + end) + + it("should fire for every consumer up the chain", function() + tree = createModuleTest({ + ModuleA = [[ + return "ModuleA" + ]], + ModuleB = [[ + require(script.Parent.ModuleA) + return "ModuleB" + ]], + ModuleC = [[ + require(script.Parent.ModuleB) + return "ModuleC" + ]], + }) + + local count = 0 + loader.loadedModuleChanged:Connect(function(module) + for _, child in tree:GetChildren() do + if module == child then + count += 1 + end + end + end) + + loader:require(tree.ModuleC) + + tree.ModuleA.Source = "Changed" + + expect(count).toBe(3) + end) +end) + +describe("cache", function() + it("should add a module and its result to the cache", function() + local mockModuleInstance = Instance.new("ModuleScript") + + loader:cache(mockModuleInstance, mockModuleSource) + + local cachedModule = loader._cache[mockModuleInstance:GetFullName()] + + expect(cachedModule).toBeDefined() + expect(cachedModule.result).toBe(mockModuleSource) + end) +end) + +describe("require", function() + it("should add the module to the cache", function() + local mockModuleInstance = Instance.new("ModuleScript") + + loader:require(mockModuleInstance) + expect(loader._cache[mockModuleInstance:GetFullName()]).toBeDefined() + end) + + it("should return cached results", function() + tree = createModuleTest({ + -- We return a table since it can act as a unique symbol. So if + -- both consumers are getting the same table we can perform an + -- equality check + SharedModule = [[ + local module = {} + return module + ]], + Consumer1 = [[ + local sharedModule = require(script.Parent.SharedModule) + return sharedModule + ]], + Consumer2 = [[ + local sharedModule = require(script.Parent.SharedModule) + return sharedModule + ]], + }) + + local sharedModuleFromConsumer1 = loader:require(tree.Consumer1) + local sharedModuleFromConsumer2 = loader:require(tree.Consumer2) + + expect(sharedModuleFromConsumer1).toBe(sharedModuleFromConsumer2) + end) + + it("should add the calling script as a consumer", function() + tree = createModuleTest({ + SharedModule = [[ + local module = {} + return module + ]], + Consumer = [[ + local sharedModule = require(script.Parent.SharedModule) + return sharedModule + ]], + }) + + loader:require(tree.Consumer) + + local cachedModule = loader._cache[tree.SharedModule:GetFullName()] + + expect(cachedModule).toBeDefined() + expect(cachedModule.consumers[tree.Consumer:GetFullName()]).toBeDefined() + end) + + it("should update consumers when requiring a cached module from a different script", function() + tree = createModuleTest({ + SharedModule = [[ + local module = {} + return module + ]], + Consumer1 = [[ + local sharedModule = require(script.Parent.SharedModule) + return sharedModule + ]], + Consumer2 = [[ + local sharedModule = require(script.Parent.SharedModule) + return sharedModule + ]], + }) + + loader:require(tree.Consumer1) + + local cachedModule = loader._cache[tree.SharedModule:GetFullName()] + + expect(cachedModule.consumers[tree.Consumer1:GetFullName()]).toBeDefined() + expect(cachedModule.consumers[tree.Consumer2:GetFullName()]).toBeUndefined() + + loader:require(tree.Consumer2) + + expect(cachedModule.consumers[tree.Consumer1:GetFullName()]).toBeDefined() + expect(cachedModule.consumers[tree.Consumer2:GetFullName()]).toBeDefined() + end) + + it("should keep track of _G between modules", function() + tree = createModuleTest({ + WriteGlobal = [[ + _G.foo = true + return nil + ]], + ReadGlobal = [[ + return _G.foo + ]], + }) + + loader:require(tree.WriteGlobal) + + expect(loader._globals.foo).toBe(true) + + local result = loader:require(tree.ReadGlobal) + + expect(result).toBe(true) + end) + + it("should keep track of _G in nested requires", function() + tree = createModuleTest({ + DefineGlobal = [[ + _G.foo = true + return nil + ]], + UseGlobal = [[ + require(script.Parent.DefineGlobal) + return _G.foo + ]], + }) + + local result = loader:require(tree.UseGlobal) + + expect(result).toBe(true) + + loader:clear() + + expect(loader._globals.foo).toBeUndefined() + end) + + it("should add globals on _G to the cachedModule's globals", function() + tree = createModuleTest({ + DefineGlobal = [[ + _G.foo = true + return nil + ]], + }) + + loader:require(tree.DefineGlobal) + + local cachedModule = loader._cache[tree.DefineGlobal:GetFullName()] + expect(cachedModule.globals.foo).toBe(true) + end) +end) + +describe("clearModule", function() + it("should clear a module from the cache", function() + tree = createModuleTest({ + Module = [[ + return "Module" + ]], + }) + + loader:require(tree.Module) + + expect(loader._cache[tree.Module:GetFullName()]).toBeDefined() + + loader:clearModule(tree.Module) + + expect(loader._cache[tree.Module:GetFullName()]).toBeUndefined() + end) + + it("should clear all consumers of a module from the cache", function() + tree = createModuleTest({ + SharedModule = [[ + local module = {} + return module + ]], + Consumer1 = [[ + local sharedModule = require(script.Parent.SharedModule) + return sharedModule + ]], + Consumer2 = [[ + local sharedModule = require(script.Parent.SharedModule) + return sharedModule + ]], + }) + + loader:require(tree.Consumer1) + loader:require(tree.Consumer2) + + expect(loader._cache[tree.Consumer1:GetFullName()]).toBeDefined() + expect(loader._cache[tree.Consumer2:GetFullName()]).toBeDefined() + expect(loader._cache[tree.SharedModule:GetFullName()]).toBeDefined() + + loader:clearModule(tree.SharedModule) + + expect(loader._cache[tree.Consumer1:GetFullName()]).toBeUndefined() + expect(loader._cache[tree.Consumer2:GetFullName()]).toBeUndefined() + expect(loader._cache[tree.SharedModule:GetFullName()]).toBeUndefined() + end) + + it("should only clear modules in the consumer chain", function() + tree = createModuleTest({ + Module = [[ + return nil + ]], + Consumer = [[ + require(script.Parent.Module) + return nil + ]], + Independent = [[ + return nil + ]], + }) + + loader:require(tree.Consumer) + loader:require(tree.Independent) + + expect(countDict(loader._cache)).toBe(3) + + loader:clearModule(tree.Module) + + expect(countDict(loader._cache)).toBe(1) + expect(loader._cache[tree.Independent:GetFullName()]).toBeDefined() + end) + + it("should clear all globals that a module supplied", function() + tree = createModuleTest({ + DefineGlobalFoo = [[ + _G.foo = true + return nil + ]], + DefineGlobalBar = [[ + _G.bar = false + return nil + ]], + }) + + loader:require(tree.DefineGlobalFoo) + loader:require(tree.DefineGlobalBar) + + loader:clearModule(tree.DefineGlobalBar) + + expect(loader._globals.foo).toBeDefined() + expect(loader._globals.bar).toBeUndefined() + end) + + it("should fire loadedModuleChanged when clearing a module", function() + tree = createModuleTest({ + Module = [[ + return nil + ]], + Consumer = [[ + require(script.Parent.Module) + return nil + ]], + }) + + local wasFired = false + + loader.loadedModuleChanged:Connect(function() + wasFired = true + end) + + loader:require(tree.Consumer) + loader:clearModule(tree.Consumer) + + expect(wasFired).toBe(true) + end) + + it("should fire loadedModuleChanged for every module up the chain", function() + tree = createModuleTest({ + Module3 = [[ + return {} + ]], + Module2 = [[ + require(script.Parent.Module3) + return {} + ]], + Module1 = [[ + require(script.Parent.Module2) + return {} + ]], + Consumer = [[ + require(script.Parent.Module1) + return nil + ]], + }) + + local count = 0 + + loader.loadedModuleChanged:Connect(function() + count += 1 + end) + + loader:require(tree.Consumer) + loader:clearModule(tree.Module3) + + expect(count).toBe(4) + end) + + it("should not fire loadedModuleChanged for a module that hasn't been required", function() + local wasFired = false + + loader.loadedModuleChanged:Connect(function() + wasFired = true + end) + + -- Do nothing if the module hasn't been cached + local module = Instance.new("ModuleScript") + loader:clearModule(module) + expect(wasFired).toBe(false) + end) +end) + +describe("clear", function() + it("should remove all modules from the cache", function() + local mockModuleInstance = Instance.new("ModuleScript") + + loader:cache(mockModuleInstance, mockModuleSource) + + expect(countDict(loader._cache)).toBe(1) + + loader:clear() + + expect(countDict(loader._cache)).toBe(0) + end) + + it("should reset globals", function() + local globals = loader._globals + + loader:clear() + + expect(loader._globals).never.toBe(globals) + end) +end) + +describe("consumers", function() + beforeEach(function() + tree = createModuleTest({ + ModuleA = [[ + require(script.Parent.ModuleB) + + return "ModuleA" + ]], + ModuleB = [[ + return "ModuleB" + ]], + + ModuleC = [[ + return "ModuleC" + ]], + }) + end) + + it("should remove all consumers of a changed module from the cache", function() + loader:require(tree.ModuleA) + + local hasItems = next(loader._cache) ~= nil + expect(hasItems).toBe(true) + + tree.ModuleB.Source = 'return "ModuleB Reloaded"' + task.wait() + + hasItems = next(loader._cache) ~= nil + expect(hasItems).toBe(false) + end) + + it("should not interfere with other cached modules", function() + loader:require(tree.ModuleA) + loader:require(tree.ModuleC) + + local hasItems = next(loader._cache) ~= nil + expect(hasItems).toBe(true) + + tree.ModuleB.Source = 'return "ModuleB Reloaded"' + task.wait() + + expect(loader._cache[tree.ModuleA:GetFullName()]).toBeUndefined() + expect(loader._cache[tree.ModuleB:GetFullName()]).toBeUndefined() + expect(loader._cache[tree.ModuleC:GetFullName()]).toBeDefined() + end) +end) + +describe("roblox-ts", function() + local rbxtsInclude + local mockRuntime + + beforeEach(function() + rbxtsInclude = Instance.new("Folder") + rbxtsInclude.Name = "rbxts_include" + + mockRuntime = Instance.new("ModuleScript") + mockRuntime.Name = "RuntimeLib" + mockRuntime.Source = [[ + local function import(...) + return require(...) + end + return { + import = import + } + ]] + mockRuntime.Parent = rbxtsInclude + + rbxtsInclude.Parent = ReplicatedStorage + end) + + afterEach(function() + loader:clear() + rbxtsInclude:Destroy() + end) + + it("clearModule() should never clear the roblox-ts runtime from the cache", function() + -- This example isn't quite how a roblox-ts project would be setup + -- in practice since the require's for `Shared` would be using + -- `TS.import`, but it should be close enough for our test case + tree = createModuleTest({ + Shared = [[ + local TS = require(game:GetService("ReplicatedStorage").rbxts_include.RuntimeLib) + return {} + ]], + Module1 = [[ + local TS = require(game:GetService("ReplicatedStorage").rbxts_include.RuntimeLib) + local Shared = TS.import(script.Parent.Shared) + return nil + ]], + Module2 = [[ + local TS = require(game:GetService("ReplicatedStorage").rbxts_include.RuntimeLib) + local Shared = TS.import(script.Parent.Shared) + return nil + ]], + Root = [[ + local TS = require(game:GetService("ReplicatedStorage").rbxts_include.RuntimeLib) + local Module1 = TS.import(script.Parent.Module1) + local Module2 = TS.import(script.Parent.Module2) + ]], + }) + + loader:require(tree.Root) + loader:clearModule(tree.Shared) + + expect(loader._cache[mockRuntime:GetFullName()]).toBeDefined() + expect(loader._cache[tree.Shared:GetFullName()]).toBeUndefined() + expect(loader._cache[tree.Module1:GetFullName()]).toBeUndefined() + expect(loader._cache[tree.Module2:GetFullName()]).toBeUndefined() + expect(loader._cache[tree.Root:GetFullName()]).toBeUndefined() + end) + + it("clear() should clear the roblox-ts runtime when calling", function() + tree = createModuleTest({ + Module = [[ + local TS = require(game:GetService("ReplicatedStorage").rbxts_include.RuntimeLib) + ]], + }) + + loader:require(tree.Module) + loader:clear() + + expect(loader._cache[mockRuntime:GetFullName()]).toBeUndefined() + expect(loader._cache[tree.Module:GetFullName()]).toBeUndefined() + end) +end) diff --git a/src/bind.lua b/src/bind.luau similarity index 100% rename from src/bind.lua rename to src/bind.luau diff --git a/src/bind.spec.lua b/src/bind.spec.lua deleted file mode 100644 index 79c643f..0000000 --- a/src/bind.spec.lua +++ /dev/null @@ -1,36 +0,0 @@ -return function() - local bind = require(script.Parent.bind) - - it("should bind 'self' to the given callback", function() - local module = { - value = "foo", - callback = function(self) - return self.value - end, - } - - local callback = bind(module, module.callback) - - expect(callback()).to.equal("foo") - end) - - it("should work for the usage example", function() - local Class = {} - Class.__index = Class - - function Class.new() - local self = {} - self.value = "foo" - return setmetatable(self, Class) - end - - function Class:getValue() - return self.value - end - - local instance = Class.new() - local getValue = bind(instance, instance.getValue) - - expect(getValue()).to.equal("foo") - end) -end diff --git a/src/bind.spec.luau b/src/bind.spec.luau new file mode 100644 index 0000000..570faff --- /dev/null +++ b/src/bind.spec.luau @@ -0,0 +1,38 @@ +local JestGlobals = require("@pkg/JestGlobals") +local it = JestGlobals.it +local expect = JestGlobals.expect + +local bind = require("./bind") + +it("should bind 'self' to the given callback", function() + local module = { + value = "foo", + callback = function(self) + return self.value + end, + } + + local callback = bind(module, module.callback) + + expect(callback()).toBe("foo") +end) + +it("should work for the usage example", function() + local Class = {} + Class.__index = Class + + function Class.new() + local self = {} + self.value = "foo" + return setmetatable(self, Class) + end + + function Class:getValue() + return self.value + end + + local instance = Class.new() + local getValue = bind(instance, instance.getValue) + + expect(getValue()).toBe("foo") +end) diff --git a/src/createTablePassthrough.lua b/src/createTablePassthrough.luau similarity index 100% rename from src/createTablePassthrough.lua rename to src/createTablePassthrough.luau diff --git a/src/createTablePassthrough.spec.lua b/src/createTablePassthrough.spec.lua deleted file mode 100644 index 9cf25e0..0000000 --- a/src/createTablePassthrough.spec.lua +++ /dev/null @@ -1,23 +0,0 @@ -return function() - local createTablePassthrough = require(script.Parent.createTablePassthrough) - - it("should work for the use case of maintaining global variables", function() - local allGlobals = {} - local moduleGlobals1 = createTablePassthrough(allGlobals) - local moduleGlobals2 = createTablePassthrough(allGlobals) - - moduleGlobals1.foo = true - moduleGlobals2.bar = true - - expect(moduleGlobals1.foo).to.equal(true) - expect(moduleGlobals1.bar).to.equal(true) - expect(rawget(moduleGlobals1, "bar")).never.to.be.ok() - - expect(moduleGlobals2.bar).to.equal(true) - expect(moduleGlobals2.foo).to.equal(true) - expect(rawget(moduleGlobals2, "foo")).never.to.be.ok() - - expect(allGlobals.foo).to.equal(true) - expect(allGlobals.bar).to.equal(true) - end) -end diff --git a/src/createTablePassthrough.spec.luau b/src/createTablePassthrough.spec.luau new file mode 100644 index 0000000..b11caa9 --- /dev/null +++ b/src/createTablePassthrough.spec.luau @@ -0,0 +1,25 @@ +local JestGlobals = require("@pkg/JestGlobals") +local it = JestGlobals.it +local expect = JestGlobals.expect + +local createTablePassthrough = require("./createTablePassthrough") + +it("should work for the use case of maintaining global variables", function() + local allGlobals = {} + local moduleGlobals1 = createTablePassthrough(allGlobals) + local moduleGlobals2 = createTablePassthrough(allGlobals) + + moduleGlobals1.foo = true + moduleGlobals2.bar = true + + expect(moduleGlobals1.foo).toBe(true) + expect(moduleGlobals1.bar).toBe(true) + expect(rawget(moduleGlobals1, "bar")).toBeUndefined() + + expect(moduleGlobals2.bar).toBe(true) + expect(moduleGlobals2.foo).toBe(true) + expect(rawget(moduleGlobals2, "foo")).toBeUndefined() + + expect(allGlobals.foo).toBe(true) + expect(allGlobals.bar).toBe(true) +end) diff --git a/src/getCallerPath.lua b/src/getCallerPath.luau similarity index 100% rename from src/getCallerPath.lua rename to src/getCallerPath.luau diff --git a/src/getEnv.lua b/src/getEnv.luau similarity index 100% rename from src/getEnv.lua rename to src/getEnv.luau diff --git a/src/getEnv.spec.lua b/src/getEnv.spec.lua deleted file mode 100644 index 2cabf25..0000000 --- a/src/getEnv.spec.lua +++ /dev/null @@ -1,22 +0,0 @@ -return function() - local getEnv = require(script.Parent.getEnv) - - it("should return a table", function() - expect(getEnv()).to.be.a("table") - end) - - it("should have the correct 'script' global", function() - local env = getEnv(script.Parent.getEnv) - expect(env.script).to.equal(script.Parent.getEnv) - end) - - it("should set _G to the 'globals' argument", function() - local globals = {} - local env = getEnv(script.Parent.getEnv, globals) - - expect(env._G).to.be.ok() - expect(env._G).to.equal(globals) - -- selene: allow(global_usage) - expect(env._G).never.to.equal(_G) - end) -end diff --git a/src/getEnv.spec.luau b/src/getEnv.spec.luau new file mode 100644 index 0000000..425b9b5 --- /dev/null +++ b/src/getEnv.spec.luau @@ -0,0 +1,24 @@ +local JestGlobals = require("@pkg/JestGlobals") +local it = JestGlobals.it +local expect = JestGlobals.expect + +local getEnv = require("./getEnv") + +it("should return a table", function() + expect(typeof(getEnv())).toBe("table") +end) + +it("should have the correct 'script' global", function() + local env = getEnv(script.Parent.getEnv) + expect(env.script).toBe(script.Parent.getEnv) +end) + +it("should set _G to the 'globals' argument", function() + local globals = {} + local env = getEnv(script.Parent.getEnv, globals) + + expect(env._G).toBeDefined() + expect(env._G).toBe(globals) + -- selene: allow(global_usage) + expect(env._G).never.toBe(_G) +end) diff --git a/src/getRobloxTsRuntime.lua b/src/getRobloxTsRuntime.luau similarity index 100% rename from src/getRobloxTsRuntime.lua rename to src/getRobloxTsRuntime.luau diff --git a/src/getRobloxTsRuntime.spec.lua b/src/getRobloxTsRuntime.spec.lua deleted file mode 100644 index a3a83c7..0000000 --- a/src/getRobloxTsRuntime.spec.lua +++ /dev/null @@ -1,26 +0,0 @@ -return function() - local ReplicatedStorage = game:GetService("ReplicatedStorage") - - local getRobloxTsRuntime = require(script.Parent.getRobloxTsRuntime) - - it("should retrieve the roblox-ts runtime library", function() - local includes = Instance.new("Folder") - includes.Name = "rbxts_include" - includes.Parent = ReplicatedStorage - - local mockRuntime = Instance.new("ModuleScript") - mockRuntime.Name = "RuntimeLib" - mockRuntime.Parent = includes - - local runtime = getRobloxTsRuntime() - - includes:Destroy() - - expect(runtime == mockRuntime).to.equal(true) - end) - - it("should return nil if the runtime can't be found", function() - local runtime = getRobloxTsRuntime() - expect(runtime).never.to.be.ok() - end) -end diff --git a/src/getRobloxTsRuntime.spec.luau b/src/getRobloxTsRuntime.spec.luau new file mode 100644 index 0000000..b5ffe96 --- /dev/null +++ b/src/getRobloxTsRuntime.spec.luau @@ -0,0 +1,28 @@ +local ReplicatedStorage = game:GetService("ReplicatedStorage") + +local JestGlobals = require("@pkg/JestGlobals") +local it = JestGlobals.it +local expect = JestGlobals.expect + +local getRobloxTsRuntime = require("./getRobloxTsRuntime") + +it("should retrieve the roblox-ts runtime library", function() + local includes = Instance.new("Folder") + includes.Name = "rbxts_include" + includes.Parent = ReplicatedStorage + + local mockRuntime = Instance.new("ModuleScript") + mockRuntime.Name = "RuntimeLib" + mockRuntime.Parent = includes + + local runtime = getRobloxTsRuntime() + + includes:Destroy() + + expect(runtime == mockRuntime).toBe(true) +end) + +it("should return nil if the runtime can't be found", function() + local runtime = getRobloxTsRuntime() + expect(runtime).toBeUndefined() +end) diff --git a/src/init.luau b/src/init.luau new file mode 100644 index 0000000..4278cfc --- /dev/null +++ b/src/init.luau @@ -0,0 +1,6 @@ +local ModuleLoader = require("./ModuleLoader") + +export type ModuleLoader = ModuleLoader.ModuleLoader +export type Class = ModuleLoader.ModuleLoader + +return ModuleLoader diff --git a/src/init.spec.lua b/src/init.spec.lua deleted file mode 100644 index 8199d22..0000000 --- a/src/init.spec.lua +++ /dev/null @@ -1,638 +0,0 @@ -return function() - local ReplicatedStorage = game:GetService("ReplicatedStorage") - - local ModuleLoader = require(script.Parent) - - local function countDict(dict: { [string]: any }) - local count = 0 - for _ in pairs(dict) do - count += 1 - end - return count - end - - type ModuleTestTree = { - [string]: string | ModuleTestTree, - } - local testNumber = 0 - local function createModuleTest(tree: ModuleTestTree, parent: Instance?) - testNumber += 1 - - local root = Instance.new("Folder") - root.Name = "ModuleTest" .. testNumber - - parent = if parent then parent else root - - for name, sourceOrDescendants in tree do - if typeof(sourceOrDescendants) == "table" then - createModuleTest(sourceOrDescendants, parent) - else - local module = Instance.new("ModuleScript") - module.Name = name - module.Source = sourceOrDescendants - module.Parent = parent - end - end - - root.Parent = game - - return root - end - - local mockModuleSource = {} - local loader: ModuleLoader.Class - local tree - - beforeEach(function() - loader = ModuleLoader.new() - end) - - afterEach(function() - loader:clear() - - if tree then - tree:Destroy() - end - end) - - describe("_getSource", function() - -- This test doesn't supply much value. Essentially, the "Source" - -- property requires elevated permissions, so we need the _getSource - -- method so that that if tests are being run from within a normal - -- script context that an error will not be produced. - it("should return the Source property if it can be indexed", function() - local mockModuleInstance = Instance.new("ModuleScript") - - local canIndex = pcall(function() - return mockModuleInstance.Source - end) - - local source = loader:_getSource(mockModuleInstance) - - if canIndex then - expect(source).to.be.ok() - else - expect(source).to.never.be.ok() - end - end) - end) - - describe("_trackChanges", function() - it("should create a Janitor instance if it doesn't exist", function() - local mockModuleInstance = Instance.new("ModuleScript") - - expect(loader._janitors[mockModuleInstance.Name]).never.to.be.ok() - - loader:_trackChanges(mockModuleInstance) - - expect(loader._janitors[mockModuleInstance.Name]).to.be.ok() - end) - - it("should reuse the same Janitor instance for future calls", function() - local mockModuleInstance = Instance.new("ModuleScript") - - loader:_trackChanges(mockModuleInstance) - - local janitor = loader._janitors[mockModuleInstance.Name] - - loader:_trackChanges(mockModuleInstance) - - expect(loader._janitors[mockModuleInstance.Name]).to.equal(janitor) - end) - end) - - describe("loadedModuleChanged", function() - it("should fire when a required module has its ancestry changed", function() - local mockModuleInstance = Instance.new("ModuleScript") - - local wasFired = false - - -- Parent the ModuleScript somewhere in the DataModel so we can - -- listen for AncestryChanged. - mockModuleInstance.Parent = game - - loader.loadedModuleChanged:Connect(function(other: ModuleScript) - if other == mockModuleInstance then - wasFired = true - end - end) - - -- Require the module so that events get setup - loader:require(mockModuleInstance) - - -- Trigger AncestryChanged to fire - mockModuleInstance.Parent = nil - - expect(wasFired).to.equal(true) - end) - - it("should fire when a required module has its Source property change", function() - local mockModuleInstance = Instance.new("ModuleScript") - - local wasFired = false - loader.loadedModuleChanged:Connect(function(other: ModuleScript) - if other == mockModuleInstance then - wasFired = true - end - end) - - -- Require the module so that events get setup - loader:require(mockModuleInstance) - - mockModuleInstance.Source = "Something different" - - expect(wasFired).to.equal(true) - end) - - it("should fire for every consumer up the chain", function() - tree = createModuleTest({ - ModuleA = [[ - return "ModuleA" - ]], - ModuleB = [[ - require(script.Parent.ModuleA) - return "ModuleB" - ]], - ModuleC = [[ - require(script.Parent.ModuleB) - return "ModuleC" - ]], - }) - - local count = 0 - loader.loadedModuleChanged:Connect(function(module) - for _, child in tree:GetChildren() do - if module == child then - count += 1 - end - end - end) - - loader:require(tree.ModuleC) - - tree.ModuleA.Source = "Changed" - - expect(count).to.equal(3) - end) - end) - - describe("cache", function() - it("should add a module and its result to the cache", function() - local mockModuleInstance = Instance.new("ModuleScript") - - loader:cache(mockModuleInstance, mockModuleSource) - - local cachedModule = loader._cache[mockModuleInstance:GetFullName()] - - expect(cachedModule).to.be.ok() - expect(cachedModule.result).to.equal(mockModuleSource) - end) - end) - - describe("require", function() - it("should add the module to the cache", function() - local mockModuleInstance = Instance.new("ModuleScript") - - loader:require(mockModuleInstance) - expect(loader._cache[mockModuleInstance:GetFullName()]).to.be.ok() - end) - - it("should return cached results", function() - tree = createModuleTest({ - -- We return a table since it can act as a unique symbol. So if - -- both consumers are getting the same table we can perform an - -- equality check - SharedModule = [[ - local module = {} - return module - ]], - Consumer1 = [[ - local sharedModule = require(script.Parent.SharedModule) - return sharedModule - ]], - Consumer2 = [[ - local sharedModule = require(script.Parent.SharedModule) - return sharedModule - ]], - }) - - local sharedModuleFromConsumer1 = loader:require(tree.Consumer1) - local sharedModuleFromConsumer2 = loader:require(tree.Consumer2) - - expect(sharedModuleFromConsumer1).to.equal(sharedModuleFromConsumer2) - end) - - it("should add the calling script as a consumer", function() - tree = createModuleTest({ - SharedModule = [[ - local module = {} - return module - ]], - Consumer = [[ - local sharedModule = require(script.Parent.SharedModule) - return sharedModule - ]], - }) - - loader:require(tree.Consumer) - - local cachedModule = loader._cache[tree.SharedModule:GetFullName()] - - expect(cachedModule).to.be.ok() - expect(cachedModule.consumers[tree.Consumer:GetFullName()]).to.be.ok() - end) - - it("should update consumers when requiring a cached module from a different script", function() - tree = createModuleTest({ - SharedModule = [[ - local module = {} - return module - ]], - Consumer1 = [[ - local sharedModule = require(script.Parent.SharedModule) - return sharedModule - ]], - Consumer2 = [[ - local sharedModule = require(script.Parent.SharedModule) - return sharedModule - ]], - }) - - loader:require(tree.Consumer1) - - local cachedModule = loader._cache[tree.SharedModule:GetFullName()] - - expect(cachedModule.consumers[tree.Consumer1:GetFullName()]).to.be.ok() - expect(cachedModule.consumers[tree.Consumer2:GetFullName()]).never.to.be.ok() - - loader:require(tree.Consumer2) - - expect(cachedModule.consumers[tree.Consumer1:GetFullName()]).to.be.ok() - expect(cachedModule.consumers[tree.Consumer2:GetFullName()]).to.be.ok() - end) - - it("should keep track of _G between modules", function() - tree = createModuleTest({ - WriteGlobal = [[ - _G.foo = true - return nil - ]], - ReadGlobal = [[ - return _G.foo - ]], - }) - - loader:require(tree.WriteGlobal) - - expect(loader._globals.foo).to.equal(true) - - local result = loader:require(tree.ReadGlobal) - - expect(result).to.equal(true) - end) - - it("should keep track of _G in nested requires", function() - tree = createModuleTest({ - DefineGlobal = [[ - _G.foo = true - return nil - ]], - UseGlobal = [[ - require(script.Parent.DefineGlobal) - return _G.foo - ]], - }) - - local result = loader:require(tree.UseGlobal) - - expect(result).to.equal(true) - - loader:clear() - - expect(loader._globals.foo).never.to.be.ok() - end) - - it("should add globals on _G to the cachedModule's globals", function() - tree = createModuleTest({ - DefineGlobal = [[ - _G.foo = true - return nil - ]], - }) - - loader:require(tree.DefineGlobal) - - local cachedModule = loader._cache[tree.DefineGlobal:GetFullName()] - expect(cachedModule.globals.foo).to.equal(true) - end) - end) - - describe("clearModule", function() - it("should clear a module from the cache", function() - tree = createModuleTest({ - Module = [[ - return "Module" - ]], - }) - - loader:require(tree.Module) - - expect(loader._cache[tree.Module:GetFullName()]).to.be.ok() - - loader:clearModule(tree.Module) - - expect(loader._cache[tree.Module:GetFullName()]).never.to.be.ok() - end) - - it("should clear all consumers of a module from the cache", function() - tree = createModuleTest({ - SharedModule = [[ - local module = {} - return module - ]], - Consumer1 = [[ - local sharedModule = require(script.Parent.SharedModule) - return sharedModule - ]], - Consumer2 = [[ - local sharedModule = require(script.Parent.SharedModule) - return sharedModule - ]], - }) - - loader:require(tree.Consumer1) - loader:require(tree.Consumer2) - - expect(loader._cache[tree.Consumer1:GetFullName()]).to.be.ok() - expect(loader._cache[tree.Consumer2:GetFullName()]).to.be.ok() - expect(loader._cache[tree.SharedModule:GetFullName()]).to.be.ok() - - loader:clearModule(tree.SharedModule) - - expect(loader._cache[tree.Consumer1:GetFullName()]).never.to.be.ok() - expect(loader._cache[tree.Consumer2:GetFullName()]).never.to.be.ok() - expect(loader._cache[tree.SharedModule:GetFullName()]).never.to.be.ok() - end) - - it("should only clear modules in the consumer chain", function() - tree = createModuleTest({ - Module = [[ - return nil - ]], - Consumer = [[ - require(script.Parent.Module) - return nil - ]], - Independent = [[ - return nil - ]], - }) - - loader:require(tree.Consumer) - loader:require(tree.Independent) - - expect(countDict(loader._cache)).to.equal(3) - - loader:clearModule(tree.Module) - - expect(countDict(loader._cache)).to.equal(1) - expect(loader._cache[tree.Independent:GetFullName()]).to.be.ok() - end) - - it("should clear all globals that a module supplied", function() - tree = createModuleTest({ - DefineGlobalFoo = [[ - _G.foo = true - return nil - ]], - DefineGlobalBar = [[ - _G.bar = false - return nil - ]], - }) - - loader:require(tree.DefineGlobalFoo) - loader:require(tree.DefineGlobalBar) - - loader:clearModule(tree.DefineGlobalBar) - - expect(loader._globals.foo).to.be.ok() - expect(loader._globals.bar).never.to.be.ok() - end) - - it("should fire loadedModuleChanged when clearing a module", function() - tree = createModuleTest({ - Module = [[ - return nil - ]], - Consumer = [[ - require(script.Parent.Module) - return nil - ]], - }) - - local wasFired = false - - loader.loadedModuleChanged:Connect(function() - wasFired = true - end) - - loader:require(tree.Consumer) - loader:clearModule(tree.Consumer) - - expect(wasFired).to.equal(true) - end) - - it("should fire loadedModuleChanged for every module up the chain", function() - tree = createModuleTest({ - Module3 = [[ - return {} - ]], - Module2 = [[ - require(script.Parent.Module3) - return {} - ]], - Module1 = [[ - require(script.Parent.Module2) - return {} - ]], - Consumer = [[ - require(script.Parent.Module1) - return nil - ]], - }) - - local count = 0 - - loader.loadedModuleChanged:Connect(function() - count += 1 - end) - - loader:require(tree.Consumer) - loader:clearModule(tree.Module3) - - expect(count).to.equal(4) - end) - - it("should not fire loadedModuleChanged for a module that hasn't been required", function() - local wasFired = false - - loader.loadedModuleChanged:Connect(function() - wasFired = true - end) - - -- Do nothing if the module hasn't been cached - local module = Instance.new("ModuleScript") - loader:clearModule(module) - expect(wasFired).to.equal(false) - end) - end) - - describe("clear", function() - it("should remove all modules from the cache", function() - local mockModuleInstance = Instance.new("ModuleScript") - - loader:cache(mockModuleInstance, mockModuleSource) - - expect(countDict(loader._cache)).to.equal(1) - - loader:clear() - - expect(countDict(loader._cache)).to.equal(0) - end) - - it("should reset globals", function() - local globals = loader._globals - - loader:clear() - - expect(loader._globals).never.to.equal(globals) - end) - end) - - describe("consumers", function() - beforeEach(function() - tree = createModuleTest({ - ModuleA = [[ - require(script.Parent.ModuleB) - - return "ModuleA" - ]], - ModuleB = [[ - return "ModuleB" - ]], - - ModuleC = [[ - return "ModuleC" - ]], - }) - end) - - it("should remove all consumers of a changed module from the cache", function() - loader:require(tree.ModuleA) - - local hasItems = next(loader._cache) ~= nil - expect(hasItems).to.equal(true) - - tree.ModuleB.Source = 'return "ModuleB Reloaded"' - task.wait() - - hasItems = next(loader._cache) ~= nil - expect(hasItems).to.equal(false) - end) - - it("should not interfere with other cached modules", function() - loader:require(tree.ModuleA) - loader:require(tree.ModuleC) - - local hasItems = next(loader._cache) ~= nil - expect(hasItems).to.equal(true) - - tree.ModuleB.Source = 'return "ModuleB Reloaded"' - task.wait() - - expect(loader._cache[tree.ModuleA:GetFullName()]).never.to.be.ok() - expect(loader._cache[tree.ModuleB:GetFullName()]).never.to.be.ok() - expect(loader._cache[tree.ModuleC:GetFullName()]).to.be.ok() - end) - end) - - describe("roblox-ts", function() - local rbxtsInclude - local mockRuntime - - beforeEach(function() - rbxtsInclude = Instance.new("Folder") - rbxtsInclude.Name = "rbxts_include" - - mockRuntime = Instance.new("ModuleScript") - mockRuntime.Name = "RuntimeLib" - mockRuntime.Source = [[ - local function import(...) - return require(...) - end - return { - import = import - } - ]] - mockRuntime.Parent = rbxtsInclude - - rbxtsInclude.Parent = ReplicatedStorage - end) - - afterEach(function() - loader:clear() - rbxtsInclude:Destroy() - end) - - it("clearModule() should never clear the roblox-ts runtime from the cache", function() - -- This example isn't quite how a roblox-ts project would be setup - -- in practice since the require's for `Shared` would be using - -- `TS.import`, but it should be close enough for our test case - tree = createModuleTest({ - Shared = [[ - local TS = require(game:GetService("ReplicatedStorage").rbxts_include.RuntimeLib) - return {} - ]], - Module1 = [[ - local TS = require(game:GetService("ReplicatedStorage").rbxts_include.RuntimeLib) - local Shared = TS.import(script.Parent.Shared) - return nil - ]], - Module2 = [[ - local TS = require(game:GetService("ReplicatedStorage").rbxts_include.RuntimeLib) - local Shared = TS.import(script.Parent.Shared) - return nil - ]], - Root = [[ - local TS = require(game:GetService("ReplicatedStorage").rbxts_include.RuntimeLib) - local Module1 = TS.import(script.Parent.Module1) - local Module2 = TS.import(script.Parent.Module2) - ]], - }) - - loader:require(tree.Root) - loader:clearModule(tree.Shared) - - expect(loader._cache[mockRuntime:GetFullName()]).to.be.ok() - expect(loader._cache[tree.Shared:GetFullName()]).never.to.be.ok() - expect(loader._cache[tree.Module1:GetFullName()]).never.to.be.ok() - expect(loader._cache[tree.Module2:GetFullName()]).never.to.be.ok() - expect(loader._cache[tree.Root:GetFullName()]).never.to.be.ok() - end) - - it("clear() should clear the roblox-ts runtime when calling", function() - tree = createModuleTest({ - Module = [[ - local TS = require(game:GetService("ReplicatedStorage").rbxts_include.RuntimeLib) - ]], - }) - - loader:require(tree.Module) - loader:clear() - - expect(loader._cache[mockRuntime:GetFullName()]).never.to.be.ok() - expect(loader._cache[tree.Module:GetFullName()]).never.to.be.ok() - end) - end) -end diff --git a/src/jest.config.luau b/src/jest.config.luau new file mode 100644 index 0000000..4294d00 --- /dev/null +++ b/src/jest.config.luau @@ -0,0 +1,3 @@ +return { + testMatch = { "**/*.spec" }, +} diff --git a/src/types.lua b/src/types.lua deleted file mode 100644 index 7e9dc0f..0000000 --- a/src/types.lua +++ /dev/null @@ -1,13 +0,0 @@ -export type ModuleConsumers = { - [string]: boolean, -} - --- Each module gets its own global table that it can modify via _G. This makes --- it easy to clear out a module and the globals it defines without impacting --- other modules. A module's function environment has all globals merged --- together on _G -export type ModuleGlobals = { - [any]: any, -} - -return {} diff --git a/stylua.toml b/stylua.toml new file mode 100644 index 0000000..ed2de71 --- /dev/null +++ b/stylua.toml @@ -0,0 +1,2 @@ +[sort_requires] +enabled = true diff --git a/testez.d.lua b/testez.d.lua deleted file mode 100644 index 28bef92..0000000 --- a/testez.d.lua +++ /dev/null @@ -1,24 +0,0 @@ -declare function afterAll(callback: () -> ()): () -declare function afterEach(callback: () -> ()): () - -declare function beforeAll(callback: () -> ()): () -declare function beforeEach(callback: () -> ()): () - -declare function describe(phrase: string, callback: () -> ()): () -declare function describeFOCUS(phrase: string, callback: () -> ()): () -declare function fdescribe(phrase: string, callback: () -> ()): () -declare function describeSKIP(phrase: string, callback: () -> ()): () -declare function xdescribe(phrase: string, callback: () -> ()): () - -declare function expect(value: any): any - -declare function FIXME(optionalMessage: string?): () -declare function FOCUS(): () -declare function SKIP(): () - -declare function it(phrase: string, callback: () -> ()): () -declare function itFOCUS(phrase: string, callback: () -> ()): () -declare function fit(phrase: string, callback: () -> ()): () -declare function itSKIP(phrase: string, callback: () -> ()): () -declare function xit(phrase: string, callback: () -> ()): () -declare function itFIXME(phrase: string, callback: () -> ()): () diff --git a/testez.toml b/testez.toml deleted file mode 100644 index 528bd00..0000000 --- a/testez.toml +++ /dev/null @@ -1,78 +0,0 @@ -[[afterAll.args]] -type = "function" - -[[afterEach.args]] -type = "function" - -[[beforeAll.args]] -type = "function" - -[[beforeEach.args]] -type = "function" - -[[describe.args]] -type = "string" - -[[describe.args]] -type = "function" - -[[describeFOCUS.args]] -type = "string" - -[[describeFOCUS.args]] -type = "function" - -[[describeSKIP.args]] -type = "string" - -[[describeSKIP.args]] -type = "function" - -[[expect.args]] -type = "any" - -[[FIXME.args]] -type = "string" -required = false - -[FOCUS] -args = [] - -[[it.args]] -type = "string" - -[[it.args]] -type = "function" - -[[itFIXME.args]] -type = "string" - -[[itFIXME.args]] -type = "function" - -[[itFOCUS.args]] -type = "string" - -[[itFOCUS.args]] -type = "function" - -[[fit.args]] -type = "string" - -[[fit.args]] -type = "function" - -[[itSKIP.args]] -type = "string" - -[[itSKIP.args]] -type = "function" - -[[xit.args]] -type = "string" - -[[xit.args]] -type = "function" - -[SKIP] -args = [] diff --git a/dev.project.json b/tests.project.json similarity index 82% rename from dev.project.json rename to tests.project.json index 7e7ca73..cd03bee 100644 --- a/dev.project.json +++ b/tests.project.json @@ -1,12 +1,12 @@ { - "name": "module-loader (dev)", + "name": "Tests", "tree": { "$className": "DataModel", "ReplicatedStorage": { "Packages": { "$path": "Packages", "ModuleLoader": { - "$path": "src" + "$path": "default.project.json" } } }, diff --git a/tests/ClientAppSettings.json b/tests/ClientAppSettings.json new file mode 100644 index 0000000..6d29ac5 --- /dev/null +++ b/tests/ClientAppSettings.json @@ -0,0 +1,3 @@ +{ + "FFlagEnableLoadModule": "true" +} \ No newline at end of file diff --git a/tests/init.server.lua b/tests/init.server.lua deleted file mode 100644 index 9ec4b7f..0000000 --- a/tests/init.server.lua +++ /dev/null @@ -1,13 +0,0 @@ -local ReplicatedStorage = game:GetService("ReplicatedStorage") - -local TestEZ = require(ReplicatedStorage.Packages.TestEZ) - -local results = TestEZ.TestBootstrap:run({ - ReplicatedStorage.Packages.ModuleLoader, -}, TestEZ.Reporters.TextReporterQuiet) - -if results.failureCount > 0 then - print("❌ Test run failed") -else - print("✔️ All tests passed") -end diff --git a/tests/run-tests.luau b/tests/run-tests.luau new file mode 100644 index 0000000..6777b68 --- /dev/null +++ b/tests/run-tests.luau @@ -0,0 +1,21 @@ +local ReplicatedStorage = game:GetService("ReplicatedStorage") + +local Jest = require(ReplicatedStorage.Packages.Jest) + +local root = ReplicatedStorage.Packages.ModuleLoader + +-- selene: allow(global_usage) +_G.__DEV__ = true +-- selene: allow(global_usage) +_G.__ROACT_17_MOCK_SCHEDULER__ = true + +local status, result = Jest.runCLI(root, { + verbose = false, + ci = false, +}, { root }):awaitStatus() + +if status == "Rejected" then + print(result) +end + +return nil diff --git a/wally.toml b/wally.toml index f14ffe4..a9570d6 100644 --- a/wally.toml +++ b/wally.toml @@ -7,8 +7,8 @@ registry = "https://github.com/UpliftGames/wally-index" realm = "shared" exclude = ["*"] include = [ - "src", - "src/**", + "dist", + "dist/**", "default.project.json", "LICENSE", "README.md", @@ -18,4 +18,7 @@ include = [ [dependencies] GoodSignal = "stravant/goodsignal@0.1.1" Janitor = "howmanysmall/janitor@1.13.15" -TestEZ = "roblox/testez@0.4.1" + +#[dev-dependencies] +Jest = "jsdotlua/jest@3.6.1-rc.2" +JestGlobals = "jsdotlua/jest-globals@3.6.1-rc.2"