diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index d2ebdff4..ec0708fd 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -18,7 +18,7 @@ jobs: - uses: actions/setup-node@v3 with: - node-version: "latest" + node-version: 22 cache: "yarn" cache-dependency-path: "yarn.lock" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b52d9984..c6dcecf2 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -29,7 +29,7 @@ jobs: - uses: actions/setup-node@v3 with: - node-version: "latest" + node-version: 22 cache: "yarn" cache-dependency-path: "yarn.lock" @@ -66,7 +66,7 @@ jobs: - uses: actions/setup-node@v3 with: - node-version: "latest" + node-version: 22 cache: "yarn" cache-dependency-path: "yarn.lock" @@ -109,6 +109,12 @@ jobs: - name: Publish jest-mock to wally run: wally publish --project-path build/wally/jest-mock + - name: Publish jest-mock-genv to wally + run: wally publish --project-path build/wally/jest-mock-genv + + - name: Publish jest-mock-rbx to wally + run: wally publish --project-path build/wally/jest-mock-rbx + - name: Publish jest-roblox-shared to wally run: wally publish --project-path build/wally/jest-roblox-shared @@ -178,7 +184,6 @@ jobs: - name: Publish jest to wally run: wally publish --project-path build/wally/jest - create-release: needs: publish-package @@ -239,7 +244,7 @@ jobs: - uses: actions/setup-node@v3 with: - node-version: "latest" + node-version: 22 cache: "yarn" cache-dependency-path: "yarn.lock" diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 0bdd156c..020e09f5 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -25,7 +25,7 @@ jobs: - uses: actions/setup-node@v3 with: - node-version: "latest" + node-version: 22 cache: "yarn" cache-dependency-path: "yarn.lock" @@ -60,7 +60,7 @@ jobs: # - uses: actions/setup-node@v3 # with: - # node-version: "latest" + # node-version: 22 # cache: "yarn" # cache-dependency-path: "yarn.lock" diff --git a/.gitignore b/.gitignore index 0d2d1a09..ca31c7a1 100644 --- a/.gitignore +++ b/.gitignore @@ -40,3 +40,7 @@ roblox.toml .vscode/launch.json .idea .vscode + +jest_runner_tmp +lcov.info +rbx-aged-tool.log diff --git a/CHANGELOG.md b/CHANGELOG.md index ebb5b35b..89bdb909 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,9 +4,37 @@ * fix warning when multiple configuration are found ([#8](https://github.com/jsdotlua/jest-lua/pull/8)) * fix error message when no tests are found ([#7](https://github.com/jsdotlua/jest-lua/pull/7)) +* +## 3.10.0 (2024-10-02) +* :sparkles: Added a fallback to use `loadstring` instead of `loadmodule` in lower privileged contexts ([#392](https://github.com/Roblox/jest-roblox-internal/pull/392)) +* :sparkles: Added `redactStackTrace` option to improve stability to snapshots that contain stacktraces ([#401](https://github.com/Roblox/jest-roblox-internal/pull/401)) +* :hammer_and_wrench: Add more helpful error message when requiring `JestGlobals` outside test environment ([#405](https://github.com/Roblox/jest-roblox-internal/pull/405)) +* :hammer_and_wrench: Error when trying to load a nonexistant `PrettyFormat` plugin ([#407](https://github.com/Roblox/jest-roblox-internal/pull/407)) +* :hammer_and_wrench: Stabilize `RobloxInstance` serialization tests ([#408](https://github.com/Roblox/jest-roblox-internal/pull/408)) + +## 3.9.1 (2024-08-02) +* :bug: Fix a type analysis error in `JestRuntime` ([#403](https://github.com/Roblox/jest-roblox-internal/pull/403)) + +## 3.9.0 (2024-08-02) +* :sparkles: Support spying on Lua globals with `spyOn` ([#397](https://github.com/Roblox/jest-roblox-internal/pull/397)) +* :bug: Expose safe APIs to read and write Roblox Instance properties in the `RobloxInstance` library for `PrettyFormat` to serialize Instances safely ([#398](https://github.com/Roblox/jest-roblox-internal/pull/398)) +* :hammer_and_wrench: Clean up package manifests, READMEs and documentation ([#400](https://github.com/Roblox/jest-roblox-internal/pull/400) [#402](https://github.com/Roblox/jest-roblox-internal/pull/402)) + +## 3.8.1 (2024-06-18) +* :bug: Fix mismatched test paths between reporter and runner ([#396](https://github.com/Roblox/jest-roblox-internal/pull/395)) + +## 3.8.0 (2024-05-20) +* :sparkles: Mock task.wait ([#388](https://github.com/Roblox/jest-roblox-internal/pull/373)) + +## 3.7.0 (2024-04-10) +* :sparkles: Resolve DOM paths to FS paths if possible in `JestRunner` ([#373](https://github.com/Roblox/jest-roblox-internal/pull/373)) + +## 3.6.2 (2024-03-21) +* :sparkles: Added jest.spyOn ([#382](https://github.com/Roblox/jest-roblox-internal/pull/382)) +* :bug: Fixed jest.mock type ([#385](https://github.com/Roblox/jest-roblox-internal/pull/385)) ## 3.6.1 (2024-01-16) -* Re-release of 3.6.0 with widened promise dependency that includes older versions for maximum flexibility ([#378](https://github.com/Roblox/jest-roblox-internal/pull/378)) +* :hammer_and_wrench: Re-release of 3.6.0 with widened promise dependency that includes older versions for maximum flexibility ([#378](https://github.com/Roblox/jest-roblox-internal/pull/378)) ## 3.6.0 (2024-01-09) * :hammer_and_wrench: Upgrade promise dependency, but keep constraint wide so that all future 3.x versions are valid ([#374](https://github.com/Roblox/jest-roblox-internal/pull/374)) diff --git a/bin/ci.sh b/bin/ci.sh index e86fa9c7..8ea88d1d 100755 --- a/bin/ci.sh +++ b/bin/ci.sh @@ -14,4 +14,13 @@ robloxdev-cli run --load.model jest.project.json \ EnableSignalBehavior=true DebugForceDeferredSignalBehavior=true MaxDeferReentrancyDepth=15 \ --lua.globals=UPDATESNAPSHOT=false --lua.globals=CI=false --load.asRobloxScript --fs.readwrite="$(pwd)" -# robloxdev-cli run --load.model jest.project.json --run bin/spec.lua --testService.errorExitCode=1 --fastFlags.allOnLuau --fastFlags.overrides EnableLoadModule=true DebugDisableOptimizedBytecode=true --lua.globals=UPDATESNAPSHOT=true --lua.globals=CI=true --load.asRobloxScript --fs.readwrite="$(pwd)" +# echo "Running low privilege tests" +robloxdev-cli run --load.model jest.project.json \ + --run bin/spec.lua --testService.errorExitCode=1 \ + --fastFlags.overrides EnableLoadModule=false --load.asRobloxScript + +# Uncomment this to update snapshots +# robloxdev-cli run --load.model jest.project.json \ +# --run bin/spec.lua --testService.errorExitCode=1 \ +# --fastFlags.allOnLuau --fastFlags.overrides EnableLoadModule=true DebugDisableOptimizedBytecode=true \ +# --lua.globals=UPDATESNAPSHOT=true --lua.globals=CI=true --load.asRobloxScript --fs.readwrite="$(pwd)" diff --git a/docs/docs/CLI.md b/docs/docs/CLI.md index a5b3713a..adcb621f 100644 --- a/docs/docs/CLI.md +++ b/docs/docs/CLI.md @@ -2,7 +2,9 @@ id: cli title: runCLI Options --- -

Jest

Deviation +[![Jest](/img/jestjs.svg)](https://jest-archive-august-2023.netlify.app/docs/27.x/cli) + +![Deviation](/img/deviation.svg) The `Jest` packages exports `runCLI`, which is the main entrypoint to run Jest Lua tests. In your entrypoint script, import `runCLI` from the `Jest` package. A basic entrypoint script can look like the following: @@ -49,47 +51,90 @@ import TOCInline from "@theme/TOCInline"; ## Reference ### `ci` \[boolean] -Jest Aligned +[![Jest](/img/jestjs.svg)](https://jest-archive-august-2023.netlify.app/docs/27.x/cli#--ci) ![Aligned](/img/aligned.svg) When this option is provided, Jest Lua will assume it is running in a CI environment. This changes the behavior when a new snapshot is encountered. Instead of the regular behavior of storing a new snapshot automatically, it will fail the test and require Jest Lua to be run with `updateSnapshot`. ### `clearMocks` \[boolean] -Jest Aligned +[![Jest](/img/jestjs.svg)](https://jest-archive-august-2023.netlify.app/docs/27.x/cli#--clearmocks) ![Aligned](/img/aligned.svg) Automatically clear mock calls, instances, contexts and results before every test. Equivalent to calling [`jest.clearAllMocks()`](jest-object#jestclearallmocks) before each test. This does not remove any mock implementation that may have been provided. ### `debug` \[boolean] -Jest Aligned +[![Jest](/img/jestjs.svg)](https://jest-archive-august-2023.netlify.app/docs/27.x/cli#--debug) ![Aligned](/img/aligned.svg) Print debugging info about your Jest config. ### `expand` \[boolean] -Jest Aligned +[![Jest](/img/jestjs.svg)](https://jest-archive-august-2023.netlify.app/docs/27.x/cli#--expand) ![Aligned](/img/aligned.svg) Use this flag to show full diffs and errors instead of a patch. ### `json` \[boolean] -Jest Aligned +[![Jest](/img/jestjs.svg)](https://jest-archive-august-2023.netlify.app/docs/27.x/cli#--json) ![Aligned](/img/aligned.svg) Prints the test results in JSON. This mode will send all other test output and user messages to stderr. ### `listTests` \[boolean] -Jest Aligned +[![Jest](/img/jestjs.svg)](https://jest-archive-august-2023.netlify.app/docs/27.x/cli#--listtests) ![Aligned](/img/aligned.svg) Lists all test files that Jest Lua will run given the arguments, and exits. ### `noStackTrace` \[boolean] -Jest Aligned +[![Jest](/img/jestjs.svg)](https://jest-archive-august-2023.netlify.app/docs/27.x/cli#--nostacktrace) ![Aligned](/img/aligned.svg) Disables stack trace in test results output. +### `oldFunctionSpying` \[boolean] +![Roblox only](/img/roblox-only.svg) + +Changes how [`jest.spyOn()`](jest-object#jestspyonobject-methodname) overwrites +methods in the spied object, making it behave like older versions of Jest. + +* When `oldFunctionSpying = true`, it will overwrite the spied method with a + *mock object*. (old behaviour) +* When `oldFunctionSpying = false`, it will overwrite the spied method with a + *regular Lua function*. (new behaviour) + +Regardless of the value of `oldFunctionSpying`, the `spyOn()` function will +always return a mock object. + +```lua +-- when `oldFunctionSpying = false` (old behaviour) + +local guineaPig = { + foo = function() end +} + +local mockObj = jest.spyOn(guineaPig, "foo") +mockObj.mockReturnValue(25) + +print(typeof(guineaPig.foo)) --> table +print(typeof(mockObj)) --> table +print(guineaPig.foo == mockObj) --> true +``` + +```lua +-- when `oldFunctionSpying = true` (new behaviour) + +local guineaPig = { + foo = function() end +} + +local mockObj = jest.spyOn(guineaPig, "foo") + +print(typeof(guineaPig.foo)) --> function +print(typeof(mockObj)) --> table +print(guineaPig.foo == mockObj) --> false +``` + ### `passWithNoTests` \[boolean] -Jest Aligned +[![Jest](/img/jestjs.svg)](https://jest-archive-august-2023.netlify.app/docs/27.x/cli#--passwithnotests) ![Aligned](/img/aligned.svg) Allows the test suite to pass when no files are found. ### `resetMocks` \[boolean] -Jest Aligned +[![Jest](/img/jestjs.svg)](https://jest-archive-august-2023.netlify.app/docs/27.x/cli#--resetmocks) ![Aligned](/img/aligned.svg) Automatically reset mock state before every test. Equivalent to calling [`jest.resetAllMocks()`](jest-object#jestresetallmocks) before each test. This will lead to any mocks having their fake implementations removed but does not restore their initial implementation. @@ -98,17 +143,17 @@ Automatically reset mock state before every test. Equivalent to calling [`jest.r Automatically restore mock state and implementation before every test. Equivalent to calling [`jest.restoreAllMocks()`](JestObjectAPI.md#jestrestoreallmocks) before each test. This will lead to any mocks having their fake implementations removed and restores their initial implementation. --> ### `showConfig` \[boolean] -Jest Aligned +[![Jest](/img/jestjs.svg)](https://jest-archive-august-2023.netlify.app/docs/27.x/cli#--showconfig) ![Aligned](/img/aligned.svg) Print your Jest config and then exits. ### `testMatch` \[array<string>] -Jest API change +[![Jest](/img/jestjs.svg)](https://jest-archive-august-2023.netlify.app/docs/27.x/cli#--testmatch-glob1--globn) ![API Change](/img/apichange.svg) The glob patterns Jest uses to detect test files. Please refer to the [`testMatch` configuration](configuration#testmatch-arraystring) for details. ### `testNamePattern` \[regex] -Jest Aligned +[![Jest](/img/jestjs.svg)](https://jest-archive-august-2023.netlify.app/docs/27.x/cli#--testnamepatternregex) ![Aligned](/img/aligned.svg) Run only tests with a name that matches the regex. For example, suppose you want to run only tests related to authorization which will have names like "GET /api/posts with auth", then you can use `testNamePattern = "auth"`. @@ -119,26 +164,26 @@ The regex is matched against the full name, which is a combination of the test n ::: ### `testPathIgnorePatterns` \[array<regex>] -Jest API change +[![Jest](/img/jestjs.svg)](https://jest-archive-august-2023.netlify.app/docs/27.x/cli#--testpathignorepatternsregexarray) ![API Change](/img/apichange.svg) An array of regexp pattern strings that are tested against all tests paths before executing the test. Contrary to `testPathPattern`, it will only run those tests with a path that does not match with the provided regexp expressions. ### `testPathPattern` \[regex] -Jest Aligned +[![Jest](/img/jestjs.svg)](https://jest-archive-august-2023.netlify.app/docs/27.x/cli#--testpathpatternregex) ![Aligned](/img/aligned.svg) A regexp pattern string that is matched against all tests paths before executing the test. ### `testTimeout` \[number>] -Jest Aligned +[![Jest](/img/jestjs.svg)](https://jest-archive-august-2023.netlify.app/docs/27.x/cli#--testtimeoutnumber) ![Aligned](/img/aligned.svg) Default timeout of a test in milliseconds. Default value: 5000. ### `updateSnapshot` \[boolean] -Jest Aligned +[![Jest](/img/jestjs.svg)](https://jest-archive-august-2023.netlify.app/docs/27.x/cli#--updatesnapshot) ![Aligned](/img/aligned.svg) Use this flag to re-record every snapshot that fails during this test run. Can be used together with a test suite pattern or with `testNamePattern` to re-record snapshots. ### `verbose` \[boolean] -Jest Aligned +[![Jest](/img/jestjs.svg)](https://jest-archive-august-2023.netlify.app/docs/27.x/cli#--verbose) ![Aligned](/img/aligned.svg) -Display individual test results with the test suite hierarchy. \ No newline at end of file +Display individual test results with the test suite hierarchy. diff --git a/docs/docs/Configuration.md b/docs/docs/Configuration.md index 66019575..33406026 100644 --- a/docs/docs/Configuration.md +++ b/docs/docs/Configuration.md @@ -2,11 +2,11 @@ id: configuration title: Configuring Jest --- -

Jest

+[![Jest](/img/jestjs.svg)](https://jest-archive-august-2023.netlify.app/docs/27.x/configuration) The Jest Lua philosophy is to work great by default, but sometimes you just need more configuration power. -deviation +![Deviation](/img/deviation.svg) The configuration should be defined in a `jest.config.lua` file. @@ -37,14 +37,14 @@ import TOCInline from "@theme/TOCInline"; ## Reference ### `clearmocks` \[boolean] -Jest Aligned +[![Jest](/img/jestjs.svg)](https://jest-archive-august-2023.netlify.app/docs/27.x/configuration#clearmocks-boolean) ![Aligned](/img/aligned.svg) Default: `false` Automatically clear mock calls, instances, contexts and results before every test. Equivalent to calling [`jest.clearAllMocks()`](jest-object#jestclearallmocks) before each test. This does not remove any mock implementation that may have been provided. ### `displayName` \[string, table] -Jest API change +[![Jest](/img/jestjs.svg)](https://jest-archive-august-2023.netlify.app/docs/27.x/configuration#displayname-string-object) ![API Change](/img/apichange.svg) Default: `nil` @@ -68,7 +68,7 @@ return { ``` ### `projects` \[array<Instance>] -Jest API change +[![Jest](/img/jestjs.svg)](https://jest-archive-august-2023.netlify.app/docs/27.x/configuration#projects-arraystring--projectconfig) ![API Change](/img/apichange.svg) Default: `nil` @@ -89,14 +89,14 @@ When using multi-project runner, it's recommended to add a `displayName` for eac ::: ### `rootDir` \[Instance] -Jest API change +[![Jest](/img/jestjs.svg)](https://jest-archive-august-2023.netlify.app/docs/27.x/configuration#rootdir-string) ![API Change](/img/apichange.svg) Default: The root of the directory containing your Jest Lua [config file](#). @@ -115,7 +115,7 @@ Using `''` as a string token in any other path-based configuration sett ::: --> ### `roots` \[array<Instance>] -Jest API change +[![Jest](/img/jestjs.svg)](https://jest-archive-august-2023.netlify.app/docs/27.x/configuration#roots-arraystring) ![API Change](/img/apichange.svg) Default: `{}` @@ -124,14 +124,14 @@ A list of paths to directories that Jest Lua should use to search for files in. There are times where you only want Jest Lua to search in a single sub-directory (such as cases where you have a `src/` directory in your repo), but prevent it from accessing the rest of the repo. ### `setupFiles` \[array<ModuleScript>] -Jest API change +[![Jest](/img/jestjs.svg)](https://jest-archive-august-2023.netlify.app/docs/27.x/configuration#setupfiles-array) ![API Change](/img/apichange.svg) Default: `{}` A list of ModuleScripts that run some code to configure or set up the testing environment. Each setupFile will be run once per test file. Since every test runs in its own environment, these scripts will be executed in the testing environment before executing [`setupFilesAfterEnv`](#setupfilesafterenv-array) and before the test code itself. ### `setupFilesAfterEnv` \[array<ModuleScript>] -Jest API change +[![Jest](/img/jestjs.svg)](https://jest-archive-august-2023.netlify.app/docs/27.x/configuration#setupfilesafterenv-array) ![API Change](/img/apichange.svg) Default: `{}` @@ -162,20 +162,20 @@ return { ``` ### `slowTestThreshold` \[number] -Jest Aligned +[![Jest](/img/jestjs.svg)](https://jest-archive-august-2023.netlify.app/docs/27.x/configuration#slowtestthreshold-number) ![Aligned](/img/aligned.svg) Default: `5` The number of seconds after which a test is considered as slow and reported as such in the results. ### `snapshotFormat` \[table] -Jest Aligned +[![Jest](/img/jestjs.svg)](https://jest-archive-august-2023.netlify.app/docs/27.x/configuration#snapshotformat-object) ![Aligned](/img/aligned.svg) Default: `nil` Allows overriding specific snapshot formatting options documented in the [pretty-format readme](https://github.com/facebook/jest/blob/main/packages/pretty-format/README.md#usage-with-options), with the exceptions of `compareKeys` and `plugins`. -deviation +![Deviation](/img/deviation.svg) `pretty-format` also supports the formatting option `printInstanceDefaults` (default: `true`) which can be set to `false` to only print properties of a Roblox `Instance` that have been changed. @@ -202,9 +202,39 @@ TextLabel { ]=] ``` +`pretty-format` further supports redacting stack traces from error logs via the +`RedactStackTraces` plugin. By default, this only attempts to redact the +contents of `Error` objects, but can be configured to search through strings +with the `redactStackTracesInStrings` boolean (default: `false`). + +For example, this lets you save snapshots that contain stack traces, without +those stack traces depending on the actual code structure of the repository. +This reduces the chance of the snapshot test breaking due to unrelated changes +in far-away parts of the code. + +```lua title="jest.config.lua" +return { + testMatch = { "**/*.spec" }, + snapshotFormat = { redactStackTracesInStrings = true } +} +``` +```lua title="test.spec.lua" +test('print stack trace', function() + expect(debug.traceback()).toMatchSnapshot() +end) +``` +```lua title="test.spec.snap.lua" +exports[ [=[print stack trace 1]=] ] = [=[ +Redacted.Stack.Trace:1337 function epicDuck +Redacted.Stack.Trace:1337 function epicDuck +Redacted.Stack.Trace:1337 function epicDuck +Redacted.Stack.Trace:1337 function epicDuck +]=] +``` + ### `snapshotSerializers` \[array<serializer>] -Jest API change +[![Jest](/img/jestjs.svg)](https://jest-archive-august-2023.netlify.app/docs/27.x/configuration#snapshotserializers-arraystring) ![API Change](/img/apichange.svg) Default: `{}` @@ -263,7 +293,7 @@ To make a dependency explicit instead of implicit, you can call [`expect.addSnap More about serializers API can be found [here](https://github.com/facebook/jest/tree/main/packages/pretty-format/README.md#serialize). ### `testFailureExitCode` \[number] -Jest Aligned +[![Jest](/img/jestjs.svg)](https://jest-archive-august-2023.netlify.app/docs/27.x/configuration#testfailureexitcode-number) ![Aligned](/img/aligned.svg) Default: `1` @@ -276,7 +306,7 @@ This does not change the exit code in the case of Jest Lua errors (e.g. invalid ::: ### `testMatch` \[array<string>] -Jest Deviation +[![Jest](/img/jestjs.svg)](https://jest-archive-august-2023.netlify.app/docs/27.x/configuration#testmatch-arraystring) ![Deviation](/img/deviation.svg) Default: `{ "**/__tests__/**/*", "**/?(*.)+(spec|test)" }` @@ -294,7 +324,7 @@ Each glob pattern is applied in the order they are specified in the config. For ::: ### `testPathIgnorePatterns` \[array<string>] -Jest Deviation +[![Jest](/img/jestjs.svg)](https://jest-archive-august-2023.netlify.app/docs/27.x/configuration#testpathignorepatterns-arraystring) ![Deviation](/img/deviation.svg) Default: `{}` @@ -303,7 +333,7 @@ An array of regexp pattern strings that are matched against all test paths befor ### `testRegex` \[string | array<string>] -Jest Deviation +[![Jest](/img/jestjs.svg)](https://jest-archive-august-2023.netlify.app/docs/27.x/configuration#testregex-string--arraystring) ![Deviation](/img/deviation.svg) Default: `{}` @@ -316,15 +346,15 @@ The pattern or patterns Jest Lua uses to detect test files. See also [`testMatch ::: ### `testTimeout` \[number] -Jest Aligned +[![Jest](/img/jestjs.svg)](https://jest-archive-august-2023.netlify.app/docs/27.x/configuration#testtimeout-number) ![Aligned](/img/aligned.svg) Default: `5000` Default timeout of a test in milliseconds. ### `verbose` \[boolean] -Jest Aligned +[![Jest](/img/jestjs.svg)](https://jest-archive-august-2023.netlify.app/docs/27.x/configuration#verbose-boolean) ![Aligned](/img/aligned.svg) Default: `false` -Indicates whether each individual test should be reported during the run. All errors will also still be shown on the bottom after execution. Note that if there is only one test file being run it will default to `true`. \ No newline at end of file +Indicates whether each individual test should be reported during the run. All errors will also still be shown on the bottom after execution. Note that if there is only one test file being run it will default to `true`. diff --git a/docs/docs/ExpectAPI.md b/docs/docs/ExpectAPI.md index f37e9530..e09521bd 100644 --- a/docs/docs/ExpectAPI.md +++ b/docs/docs/ExpectAPI.md @@ -2,11 +2,11 @@ id: expect title: Expect --- -

Jest

+[![Jest](/img/jestjs.svg)](https://jest-archive-august-2023.netlify.app/docs/27.x/expect) When you're writing tests, you often need to check that values meet certain conditions. `expect` gives you access to a number of "matchers" that let you validate different things. -deviation +![Deviation](/img/deviation.svg) It must be imported explicitly from `JestGlobals`. ```lua @@ -14,11 +14,11 @@ local expect = require("@DevPackages/JestGlobals").expect ``` ### RegExp -Roblox only +![Roblox only](/img/roblox-only.svg) To use regular expressions in matchers that support it, you need to add [LuauRegExp](https://github.com/Roblox/luau-regexp) as a dependency in your `rotriever.toml` and require it in your code. ```yaml title="rotriever.toml" -RegExp = "github.com/roblox/luau-regexp@0.2.0" +RegExp = "0.2.2" ``` ```lua @@ -26,15 +26,15 @@ local RegExp = require("@Packages/RegExp") ``` ### Promise -Roblox only +![Roblox only](/img/roblox-only.svg) To use Promises in your tests, add [roblox-lua-promise](https://github.com/Roblox/roblox-lua-promise) as a dependency in your `rotriever.toml` ```yaml -Promise = "github.com/evaera/roblox-lua-promise@3.3.0" +Promise = "3.3.0" ``` ### Error -Roblox only +![Roblox only](/img/roblox-only.svg) LuauPolyfill also provides an extensible `Error` class that can be used with throwing matchers. @@ -70,7 +70,8 @@ import TOCInline from "@theme/TOCInline"; ## Reference ### `expect(value)` -Jest Aligned +[![Jest](/img/jestjs.svg)](https://jest-archive-august-2023.netlify.app/docs/27.x/expect#expectvalue) +![Aligned](/img/aligned.svg) The `expect` function is used every time you want to test a value. You will rarely call `expect` by itself. Instead, you will use `expect` along with a "matcher" function to assert something about value. @@ -87,7 +88,8 @@ In this case, `toBe` is the matcher function. There are a lot of different match The argument to `expect` should be the value that your code produces, and any argument to the matcher should be the correct value. If you mix them up, your tests will still work, but the error messages on failing tests will look strange. ### `expect.extend(matchers)` -Jest Aligned +[![Jest](/img/jestjs.svg)](https://jest-archive-august-2023.netlify.app/docs/27.x/expect#expectextendmatchers) +![Aligned](/img/aligned.svg) You can use `expect.extend` to add your own matchers to Jest Lua. For example, let's say that you're testing a number utility library and you're frequently asserting that numbers appear within particular ranges of other numbers. You could abstract that into a `toBeWithinRange` matcher: @@ -127,7 +129,7 @@ end) ``` #### Custom Matchers API -API change +![API Change](/img/apichange.svg) Matchers should return a table with two keys. `pass` indicates whether there was a match or not, and `message` provides a function with no arguments that return an error message in case of failure. Thus, when `pass` is false, `message` should return the error message for when `expect(x).yourMatcher()` fails. And when `pass` is true, `message` should return the error message for when `expect(x).never.yourMatcher()` fails. @@ -205,7 +207,7 @@ Received: "apple" When an assertion fails, the error message should give as much signal as necessary to the user so they can resolve their issue quickly. You should craft a precise failure message to make sure users of your custom assertions have a good developer experience. #### Custom snapshot matchers -API change +![API Change](/img/apichange.svg) To use snapshot testing inside of your custom matcher you can import `JestSnapshot` and use it from within your matcher. @@ -235,7 +237,7 @@ end) ``` ### `expect.anything()` -Jest Aligned +[![Jest](/img/jestjs.svg)](https://jest-archive-august-2023.netlify.app/docs/27.x/expect#expectanything) ![Aligned](/img/aligned.svg) `expect.anything()` matches anything but `nil`. You can use it inside `toEqual` or `toBeCalledWith` instead of a literal value. For example, if you want to check that a mock function is called with a non-nil argument: @@ -248,7 +250,7 @@ end) ``` ### `expect.any(typename | prototype)` -Jest deviation +[![Jest](/img/jestjs.svg)](http://localhost:3000/expect#expectanytypename--prototype) ![Deviation](/img/deviation.svg) `expect.any(typename)` matches anything that has the given type. `expect.any(prototype)` matches anything that is an instance (or a derived instance) of the given prototype class. You can use it inside `toEqual` or `toBeCalledWith` instead of a literal value. For example: @@ -267,7 +269,7 @@ end) In addition to Lua prototype classes, it also supports Roblox types like [`DateTime`](https://developer.roblox.com/en-us/api-reference/datatype/DateTime), Luau types like `thread`, `RegExp` from the LuauRegExp library, and LuauPolyfill types like `Symbol`, `Set`, `Error` etc. ### `expect.nothing()` -Jest deviation +[![Jest](/img/jestjs.svg)](https://jest-archive-august-2023.netlify.app/docs/27.x/expect#expectnothing) ![Deviation](/img/deviation.svg) `expect.nothing()` matches only `nil`. You can use it inside `toEqual`, `toMatchObject`, `toBeCalledWith`, or similar matchers instead of a literal value. For example, if you want to check that a value is left undefined in a table: @@ -283,7 +285,7 @@ end) ``` ### `expect.arrayContaining(array)` -Jest Aligned +[![Jest](/img/jestjs.svg)](https://jest-archive-august-2023.netlify.app/docs/27.x/expect#expectarraycontainingarray) ![Aligned](/img/aligned.svg) `expect.arrayContaining(array)` matches a received array which contains all of the elements in the expected array. That is, the expected array is a **subset** of the received array. Therefore, it matches a received array which contains elements that are **not** in the expected array. @@ -321,7 +323,7 @@ end) ``` ### `expect.assertions(number)` -Jest Aligned +[![Jest](/img/jestjs.svg)](https://jest-archive-august-2023.netlify.app/docs/27.x/expect#expectassertionsnumber) ![Aligned](/img/aligned.svg) `expect.assertions(number)` verifies that a certain number of assertions are called during a test. This is often useful when testing asynchronous code, in order to make sure that assertions in a callback actually got called. @@ -345,7 +347,7 @@ end) The `expect.assertions(2)` call ensures that both callbacks actually get called. ### `expect.hasAssertions()` -Jest Aligned +[![Jest](/img/jestjs.svg)](https://jest-archive-august-2023.netlify.app/docs/27.x/expect#expecthasassertions) ![Aligned](/img/aligned.svg) `expect.hasAssertions()` verifies that at least one assertion is called during a test. This is often useful when testing asynchronous code, in order to make sure that assertions in a callback actually got called. @@ -364,7 +366,7 @@ end) The `expect.hasAssertions()` call ensures that the `prepareState` callback actually gets called. ### `expect.never.arrayContaining(array)` -Jest Aligned +[![Jest](/img/jestjs.svg)](https://jest-archive-august-2023.netlify.app/docs/27.x/expect#expectnotarraycontainingarray) ![Aligned](/img/aligned.svg) `expect.never.arrayContaining(array)` matches a received array which does not contain all of the elements in the expected array. That is, the expected array **is not a subset** of the received array. @@ -382,12 +384,12 @@ describe('never.arrayContaining', function() end) ``` -API change +![API Change](/img/apichange.svg) Also under the alias: `.arrayNotContaining(array)` ### `expect.never.objectContaining(table)` -Jest Aligned +[![Jest](/img/jestjs.svg)](https://jest-archive-august-2023.netlify.app/docs/27.x/expect#expectnotobjectcontainingobject) ![Aligned](/img/aligned.svg) `expect.never.objectContaining(table)` matches any received table that does not recursively match the expected properties. That is, the expected table **is not a subset** of the received table. Therefore, it matches a received table which contains properties that are **not** in the expected table. @@ -403,12 +405,12 @@ describe('never.objectContaining', function() end) ``` -API change +![API Change](/img/apichange.svg) Also under the alias: `.objectNotContaining(table)` ### `expect.never.stringContaining(string)` -Jest Aligned +[![Jest](/img/jestjs.svg)](https://jest-archive-august-2023.netlify.app/docs/27.x/expect#expectnotstringcontainingstring) ![Aligned](/img/aligned.svg) `expect.never.stringContaining(string)` matches the received value if it is not a string or if it is a string that does not contain the exact expected string. @@ -424,12 +426,12 @@ describe('never.stringContaining', function() end) ``` -API change +![API Change](/img/apichange.svg) Also under the alias: `.stringNotContaining(string)` ### `expect.never.stringMatching(string | regexp)` -Jest API change +[![Jest](/img/jestjs.svg)](https://jest-archive-august-2023.netlify.app/docs/27.x/expect#expectnotstringmatchingstring--regexp) ![API Change](/img/apichange.svg) `expect.never.stringMatching(string | regexp)` matches the received value if it is not a string or if it is a string that does not match the expected [Lua string pattern](https://developer.roblox.com/en-us/articles/string-patterns-reference) or [regular expression](#regexp). @@ -445,12 +447,12 @@ describe('never.stringMatching', function() end) ``` -API change +![API Change](/img/apichange.svg) Also under the alias: `.stringNotMatching(string | regexp)` ### `expect.objectContaining(table)` -Jest Aligned +[![Jest](/img/jestjs.svg)](https://jest-archive-august-2023.netlify.app/docs/27.x/expect#expectobjectcontainingobject) ![Aligned](/img/aligned.svg) `expect.objectContaining(table)` matches any received table that recursively matches the expected properties. That is, the expected table is a **subset** of the received table. Therefore, it matches a received table which contains properties that **are present** in the expected table. @@ -471,12 +473,12 @@ end) ``` ### `expect.stringContaining(string)` -Jest Aligned +[![Jest](/img/jestjs.svg)](https://jest-archive-august-2023.netlify.app/docs/27.x/expect#expectstringcontainingstring) ![Aligned](/img/aligned.svg) `expect.stringContaining(string)` matches the received value if it is a string that contains the exact expected string. ### `expect.stringMatching(string | regexp)` -Jest API change +[![Jest](/img/jestjs.svg)](https://jest-archive-august-2023.netlify.app/docs/27.x/expect#expectstringmatchingstring--regexp) ![API Change](/img/apichange.svg) `expect.stringMatching(string | regexp)` matches the received value if it is a string that matches the expected [Lua string pattern](https://developer.roblox.com/en-us/articles/string-patterns-reference) or [regular expression](#regexp). @@ -508,7 +510,7 @@ end) ``` ### `expect.addSnapshotSerializer(serializer)` -Jest API change +[![Jest](/img/jestjs.svg)](https://jest-archive-august-2023.netlify.app/docs/27.x/expect#expectaddsnapshotserializerserializer) ![API Change](/img/apichange.svg) You can call `expect.addSnapshotSerializer` to add a module that formats application-specific data structures. @@ -523,7 +525,7 @@ expect.addSnapshotSerializer(serializer) See [configuring Jest Lua](configuration) for more information. ### `.never` -Jest API change +[![Jest](/img/jestjs.svg)](https://jest-archive-august-2023.netlify.app/docs/27.x/expect#not) ![API Change](/img/apichange.svg) If you know how to test something, `.never` lets you test its opposite. For example, this code tests that the best La Croix flavor is not coconut: @@ -534,7 +536,7 @@ end) ``` ### `.resolves` -Jest Aligned +[![Jest](/img/jestjs.svg)](https://jest-archive-august-2023.netlify.app/docs/27.x/expect#resolves) ![Aligned](/img/aligned.svg) Use `resolves` to unwrap the value of a fulfilled [promise](#promise) so any other matcher can be chained. If the promise is rejected the assertion fails. @@ -554,7 +556,7 @@ Since you are still testing promises, the test is still asynchronous. Hence, you ::: ### `.rejects` -Jest Aligned +[![Jest](/img/jestjs.svg)](https://jest-archive-august-2023.netlify.app/docs/27.x/expect#rejects) ![Aligned](/img/aligned.svg) Use `.rejects` to unwrap the reason of a rejected [promise](#promise) so any other matcher can be chained. If the promise is fulfilled the assertion fails. @@ -574,7 +576,7 @@ Since you are still testing promises, the test is still asynchronous. Hence, you ::: ### `.toBe(value)` -Jest Aligned +[![Jest](/img/jestjs.svg)](https://jest-archive-august-2023.netlify.app/docs/27.x/expect#tobevalue) ![Aligned](/img/aligned.svg) Use `.toBe` to compare primitive values or to check referential identity of tables. It calls [Luau Polyfill's `Object.is`](https://github.com/Roblox/luau-polyfill/blob/main/src/Object/is.lua) to compare values, which mostly behaves like the `==` operator. @@ -605,7 +607,7 @@ Although the `.toBe` matcher **checks** referential identity, it **reports** a d - rewrite `expect(received).never.toBe(expected)` as `expect(received == expected).toBe(false)` ### `.toHaveBeenCalled()` -Jest Aligned +[![Jest](/img/jestjs.svg)](https://jest-archive-august-2023.netlify.app/docs/27.x/expect#tohavebeencalled) ![Aligned](/img/aligned.svg) Also under the alias: `.toBeCalled()` @@ -636,7 +638,7 @@ end) ``` ### `.toHaveBeenCalledTimes(number)` -Jest Aligned +[![Jest](/img/jestjs.svg)](https://jest-archive-august-2023.netlify.app/docs/27.x/expect#tohavebeencalledtimesnumber) ![Aligned](/img/aligned.svg) Also under the alias: `.toBeCalledTimes(number)` @@ -653,7 +655,7 @@ end) ``` ### `.toHaveBeenCalledWith(arg1, arg2, ...)` -Jest Aligned +[![Jest](/img/jestjs.svg)](https://jest-archive-august-2023.netlify.app/docs/27.x/expect#tohavebeencalledwitharg1-arg2-) ![Aligned](/img/aligned.svg) Also under the alias: `.toBeCalledWith()` @@ -672,7 +674,7 @@ end) ``` ### `.toHaveBeenLastCalledWith(arg1, arg2, ...)` -Jest Aligned +[![Jest](/img/jestjs.svg)](https://jest-archive-august-2023.netlify.app/docs/27.x/expect#tohavebeenlastcalledwitharg1-arg2-) ![Aligned](/img/aligned.svg) Also under the alias: `.lastCalledWith(arg1, arg2, ...)` @@ -687,7 +689,7 @@ end) ``` ### `.toHaveBeenNthCalledWith(nthCall, arg1, arg2, ....)` -Jest Aligned +[![Jest](/img/jestjs.svg)](https://jest-archive-august-2023.netlify.app/docs/27.x/expect#tohavebeennthcalledwithnthcall-arg1-arg2-) ![Aligned](/img/aligned.svg) Also under the alias: `.nthCalledWith(nthCall, arg1, arg2, ...)` @@ -705,7 +707,7 @@ end) Note: the nth argument must be positive integer starting from 1. ### `.toHaveReturned()` -Jest Aligned +[![Jest](/img/jestjs.svg)](https://jest-archive-august-2023.netlify.app/docs/27.x/expect#tohavereturned) ![Aligned](/img/aligned.svg) Also under the alias: `.toReturn()` @@ -722,7 +724,7 @@ end ``` ### `.toHaveReturnedTimes(number)` -Jest Aligned +[![Jest](/img/jestjs.svg)](https://jest-archive-august-2023.netlify.app/docs/27.x/expect#tohavereturnedtimesnumber) ![Aligned](/img/aligned.svg) Also under the alias: `.toReturnTimes(number)` @@ -742,7 +744,7 @@ end ``` ### `.toHaveReturnedWith(value)` -Jest Aligned +[![Jest](/img/jestjs.svg)](https://jest-archive-august-2023.netlify.app/docs/27.x/expect#tohavereturnedwithvalue) ![Aligned](/img/aligned.svg) Also under the alias: `.toReturnWith(value)` @@ -762,7 +764,7 @@ end) ``` ### `.toHaveLastReturnedWith(value)` -Jest Aligned +[![Jest](/img/jestjs.svg)](https://jest-archive-august-2023.netlify.app/docs/27.x/expect#tohavelastreturnedwithvalue) ![Aligned](/img/aligned.svg) Also under the alias: `.lastReturnedWith(value)` @@ -784,7 +786,7 @@ end) ``` ### `.toHaveNthReturnedWith(nthCall, value)` -Jest Aligned +[![Jest](/img/jestjs.svg)](https://jest-archive-august-2023.netlify.app/docs/27.x/expect#tohaventhreturnedwithnthcall-value) ![Aligned](/img/aligned.svg) Also under the alias: `.nthReturnedWith(nthCall, value)` @@ -809,7 +811,7 @@ end Note: the nth argument must be positive integer starting from 1. ### `.toHaveLength(number)` -Jest Deviation +[![Jest](/img/jestjs.svg)](https://jest-archive-august-2023.netlify.app/docs/27.x/expect#tohavelengthnumber) ![Deviation](/img/deviation.svg) Use `.toHaveLength` to check that an (array-like) table or string has a certain length. It calls the `#` operator and since `#` is only well defined for non-sparse array-like tables and strings it will return 0 for tables with key-value pairs. It checks the `.length` property of the table instead if it has one. @@ -822,7 +824,7 @@ expect('').never.toHaveLength(5) ``` ### `.toHaveProperty(keyPath, value?)` -Jest Aligned +[![Jest](/img/jestjs.svg)](https://jest-archive-august-2023.netlify.app/docs/27.x/expect#tohavepropertykeypath-value) ![Aligned](/img/aligned.svg) Use `.toHaveProperty` to check if property at provided reference `keyPath` exists for an object. For checking deeply nested properties in an object you may use dot notation or an array containing the `keyPath` for deep references. @@ -877,7 +879,7 @@ end) ``` ### `.toBeCloseTo(number, numDigits?)` -Jest Aligned +[![Jest](/img/jestjs.svg)](https://jest-archive-august-2023.netlify.app/docs/27.x/expect#tobeclosetonumber-numdigits) ![Aligned](/img/aligned.svg) Use `toBeCloseTo` to compare floating point numbers for approximate equality. @@ -902,7 +904,7 @@ end) ``` ### `.toBeDefined()` -Jest Deviation +[![Jest](/img/jestjs.svg)](https://jest-archive-august-2023.netlify.app/docs/27.x/expect#tobedefined) ![Deviation](/img/deviation.svg) Use `.toBeDefined` to check that a variable is not `nil`. For example, if you want to check that a function `fetchNewFlavorIdea()` returns _something_, you can write: @@ -917,7 +919,7 @@ end) ::: ### `.toBeFalsy()` -Jest Deviation +[![Jest](/img/jestjs.svg)](https://jest-archive-august-2023.netlify.app/docs/27.x/expect#tobefalsy) ![Deviation](/img/deviation.svg) Use `.toBeFalsy` when you don't care what a value is and you want to ensure a value is false in a boolean context. For example, let's say you have some application code that looks like: @@ -940,7 +942,7 @@ end) In Lua, there are two falsy values: `false` and `nil`. Everything else is truthy. ### `.toBeGreaterThan(number)` -Jest API change +[![Jest](/img/jestjs.svg)](https://jest-archive-august-2023.netlify.app/docs/27.x/expect#tobegreaterthannumber--bigint) ![API Change](/img/apichange.svg) Use `toBeGreaterThan` to compare `received > expected` for number values. For example, test that `ouncesPerCan()` returns a value of more than 10 ounces: @@ -951,7 +953,7 @@ end) ``` ### `.toBeGreaterThanOrEqual(number)` -Jest API change +[![Jest](/img/jestjs.svg)](https://jest-archive-august-2023.netlify.app/docs/27.x/expect#tobegreaterthanorequalnumber--bigint) ![API Change](/img/apichange.svg) Use `toBeGreaterThanOrEqual` to compare `received >= expected` for number values. For example, test that `ouncesPerCan()` returns a value of at least 12 ounces: @@ -962,7 +964,7 @@ end) ``` ### `.toBeLessThan(number)` -Jest API change +[![Jest](/img/jestjs.svg)](https://jest-archive-august-2023.netlify.app/docs/27.x/expect#tobelessthannumber--bigint) ![API Change](/img/apichange.svg) Use `toBeLessThan` to compare `received < expected` for number values. For example, test that `ouncesPerCan()` returns a value of less than 20 ounces: @@ -973,7 +975,7 @@ end) ``` ### `.toBeLessThanOrEqual(number)` -Jest API change +[![Jest](/img/jestjs.svg)](https://jest-archive-august-2023.netlify.app/docs/27.x/expect#tobelessthanorequalnumber--bigint) ![API Change](/img/apichange.svg) Use `toBeLessThanOrEqual` to compare `received <= expected` for number values. For example, test that `ouncesPerCan()` returns a value of at most 12 ounces: @@ -984,7 +986,7 @@ end) ``` ### `.toBeInstanceOf(prototype)` -Jest Deviation +[![Jest](/img/jestjs.svg)](https://jest-archive-august-2023.netlify.app/docs/27.x/expect#tobeinstanceofclass) ![Deviation](/img/deviation.svg) Use `.toBeInstanceOf(prototype)` to check that a value is an instance (or a derived instance) of a prototype class. This matcher uses the [`instanceof` method in LuauPolyfill](https://github.com/Roblox/luau-polyfill/blob/main/src/instanceof.lua) underneath. @@ -1023,7 +1025,7 @@ expect(C.new()).toBeInstanceOf(B) ``` ### `.toBeNil()` -Jest API change +[![Jest](/img/jestjs.svg)](https://jest-archive-august-2023.netlify.app/docs/27.x/expect#tobenull) ![API Change](/img/apichange.svg) Also under the alias: `.toBeNull()` @@ -1040,7 +1042,7 @@ end) ``` ### `.toBeTruthy()` -Jest Deviation +[![Jest](/img/jestjs.svg)](https://jest-archive-august-2023.netlify.app/docs/27.x/expect#tobetruthy) ![Deviation](/img/deviation.svg) Use `.toBeTruthy` when you don't care what a value is and you want to ensure a value is true in a boolean context. For example, let's say you have some application code that looks like: @@ -1063,7 +1065,7 @@ end) In Lua, there are two falsy values: `false` and `nil`. Everything else is truthy. ### `.toBeUndefined()` -Jest Deviation +[![Jest](/img/jestjs.svg)](https://jest-archive-august-2023.netlify.app/docs/27.x/expect#tobeundefined) ![Deviation](/img/deviation.svg) Use `.toBeUndefined()` to check that a variable is `nil`. @@ -1072,7 +1074,7 @@ Use `.toBeUndefined()` to check that a variable is `nil`. ::: ### `.toBeNan()` -Jest API change +[![Jest](/img/jestjs.svg)](https://jest-archive-august-2023.netlify.app/docs/27.x/expect#tobenan) ![API Change](/img/apichange.svg) Also under the alias: `.toBeNaN()` @@ -1086,7 +1088,7 @@ end) ``` ### `.toContain(item)` -Jest Aligned +[![Jest](/img/jestjs.svg)](https://jest-archive-august-2023.netlify.app/docs/27.x/expect#tocontainitem) ![Aligned](/img/aligned.svg) Use `.toContain` when you want to check that an item is in an array. For testing the items in the array, this uses `table.find`, which does a strict equality check. `.toContain` can also check whether a string is a substring of another string. This uses `string.find` with `plain = true` so magic characters are ignored. @@ -1099,7 +1101,7 @@ end) ``` ### `.toContainEqual(item)` -Jest Aligned +[![Jest](/img/jestjs.svg)](https://jest-archive-august-2023.netlify.app/docs/27.x/expect#tocontainequalitem) ![Aligned](/img/aligned.svg) Use `.toContainEqual` when you want to check that an item with a specific structure and values is contained in an array. For testing the items in the array, this matcher recursively checks the equality of all fields, rather than checking for table identity. @@ -1113,7 +1115,7 @@ end) ``` ### `.toEqual(value)` -Jest Aligned +[![Jest](/img/jestjs.svg)](https://jest-archive-august-2023.netlify.app/docs/27.x/expect#toequalvalue) ![Aligned](/img/aligned.svg) Use `.toEqual` to compare recursively all properties of tables (also known as "deep" equality). It calls [Luau Polyfill's `Object.is`](https://github.com/Roblox/luau-polyfill/blob/main/src/Object/is.lua) to compare primitive values, which mostly behaves like the `==` operator. @@ -1149,7 +1151,7 @@ If differences between properties do not help you to understand why a test fails - rewrite `expect(received).never.toEqual(expected)` as `expect(received.equals(expected)).toBe(false)` ### `.toMatch(string | regexp)` -Jest API change +[![Jest](/img/jestjs.svg)](https://jest-archive-august-2023.netlify.app/docs/27.x/expect#tomatchregexp--string) ![API Change](/img/apichange.svg) Use `.toMatch` to check that a string matches a [Lua string pattern](https://developer.roblox.com/en-us/articles/string-patterns-reference). @@ -1174,7 +1176,7 @@ end) ``` ### `.toMatchInstance(table)` -Roblox only +![Roblox only](/img/roblox-only.svg) Use `.toMatchObject` to check that a Roblox Instance and its children matches all the properties defined in an expected table. @@ -1203,7 +1205,7 @@ end) ``` ### `.toMatchObject(table)` -Jest Aligned +[![Jest](/img/jestjs.svg)](https://jest-archive-august-2023.netlify.app/docs/27.x/expect#tomatchobjectobject) ![Aligned](/img/aligned.svg) Use `.toMatchObject` to check that a table matches a subset of the properties of an expected table. It will match received tables with properties that are **not** in the expected table. @@ -1250,7 +1252,7 @@ end) ``` ### `.toMatchSnapshot(propertyMatchers?, hint?)` -Jest Aligned +[![Jest](/img/jestjs.svg)](https://jest-archive-august-2023.netlify.app/docs/27.x/expect#tomatchsnapshotpropertymatchers-hint) ![Aligned](/img/aligned.svg) This ensures that a value matches the most recent snapshot. Check out [the Snapshot Testing guide](snapshot-testing) for more information. @@ -1259,7 +1261,7 @@ You can provide an optional `propertyMatchers` table argument, which has asymmet You can provide an optional `hint` string argument that is appended to the test name. Although Jest always appends a number at the end of a snapshot name, short descriptive hints might be more useful than numbers to differentiate **multiple** snapshots in a **single** `it` or `test` block. Jest sorts snapshots by name in the corresponding `.snap` file. ### `.toStrictEqual(value)` -Jest Deviation +[![Jest](/img/jestjs.svg)](https://jest-archive-august-2023.netlify.app/docs/27.x/expect#tostrictequalvalue) ![Deviation](/img/deviation.svg) Use `.toStrictEqual` to test that objects have the same types. @@ -1284,7 +1286,7 @@ end) ``` ### `.toThrow(error?)` -Jest Aligned +[![Jest](/img/jestjs.svg)](https://jest-archive-august-2023.netlify.app/docs/27.x/expect#tothrowerror) ![Aligned](/img/aligned.svg) Also under the alias: `.toThrowError(error?)` @@ -1307,7 +1309,7 @@ You can provide an optional argument to test that a specific error is thrown: - [regular expression](#regexp): error message **matches** the pattern - string: error message **includes** the substring -API change +![API Change](/img/apichange.svg) `.toThrow` can also handle custom Error objects provided by LuauPolyfill: @@ -1355,7 +1357,7 @@ end) ``` ### `.toThrowErrorMatchingSnapshot(hint?)` -Jest Aligned +[![Jest](/img/jestjs.svg)](https://jest-archive-august-2023.netlify.app/docs/27.x/expect#tothrowerrormatchingsnapshothint) ![Aligned](/img/aligned.svg) Use `.toThrowErrorMatchingSnapshot` to test that a function throws an error matching the most recent snapshot when it is called. diff --git a/docs/docs/GettingStarted.md b/docs/docs/GettingStarted.md index bee80b61..5dbdad8f 100644 --- a/docs/docs/GettingStarted.md +++ b/docs/docs/GettingStarted.md @@ -4,7 +4,7 @@ title: Getting Started slug: / --- -The Jest Lua API is similar to [the API used by JavaScript Jest.](https://jestjs.io/docs/27.x/api) +The Jest Roblox API is similar to [the API used by JavaScript Jest.](https://jest-archive-august-2023.netlify.app/docs/27.x/api) Jest Lua currently requires [`run-in-roblox`](https://github.com/rojo-rbx/run-in-roblox) to run from the command line. It can also be run directly inside of Roblox Studio. See issue [#2](https://github.com/jsdotlua/jest-lua/issues/2) for more. diff --git a/docs/docs/GlobalAPI.md b/docs/docs/GlobalAPI.md index a5faf6d7..60600238 100644 --- a/docs/docs/GlobalAPI.md +++ b/docs/docs/GlobalAPI.md @@ -2,9 +2,9 @@ id: api title: Globals --- -

Jest

+[![Jest](/img/jestjs.svg)](https://jest-archive-august-2023.netlify.app/docs/27.x/api) -deviation +![Deviation](/img/deviation.svg) At the top of your test files, require `JestGlobals` from the `Packages` directory created by `rotriever`. @@ -30,7 +30,7 @@ import TOCInline from "@theme/TOCInline"; ## Reference ### `afterAll(fn, timeout)` -Jest Aligned +[![Jest](/img/jestjs.svg)](https://jest-archive-august-2023.netlify.app/docs/27.x/api#afterallfn-timeout) ![Aligned](/img/aligned.svg) Runs a function after all the tests in this file have completed. If the function returns a promise or is a generator, Jest Lua waits for that promise to resolve before continuing. @@ -71,7 +71,7 @@ If `afterAll` is inside a `describe` block, it runs at the end of the describe b If you want to run some cleanup after every test instead of after all tests, use `afterEach` instead. ### `afterEach(fn, timeout)` -Jest Aligned +[![Jest](/img/jestjs.svg)](https://jest-archive-august-2023.netlify.app/docs/27.x/api#aftereachfn-timeout) ![Aligned](/img/aligned.svg) Runs a function after each one of the tests in this file completes. If the function returns a promise or is a generator, Jest Lua waits for that promise to resolve before continuing. @@ -112,7 +112,7 @@ If `afterEach` is inside a `describe` block, it only runs after the tests that a If you want to run some cleanup just once, after all of the tests run, use `afterAll` instead. ### `beforeAll(fn, timeout)` -Jest Aligned +[![Jest](/img/jestjs.svg)](https://jest-archive-august-2023.netlify.app/docs/27.x/api#beforeallfn-timeout) ![Aligned](/img/aligned.svg) Runs a function before any of the tests in this file run. If the function returns a promise or is a generator, Jest Lua waits for that promise to resolve before running tests. @@ -150,7 +150,7 @@ If `beforeAll` is inside a `describe` block, it runs at the beginning of the des If you want to run something before every test instead of before any test runs, use `beforeEach` instead. ### `beforeEach(fn, timeout)` -Jest Aligned +[![Jest](/img/jestjs.svg)](https://jest-archive-august-2023.netlify.app/docs/27.x/api#beforeeachfn-timeout) ![Aligned](/img/aligned.svg) Runs a function before each of the tests in this file runs. If the function returns a promise or is a generator, Jest Lua waits for that promise to resolve before running the test. @@ -192,7 +192,7 @@ If `beforeEach` is inside a `describe` block, it runs for each test in the descr If you only need to run some setup code once, before any tests run, use `beforeAll` instead. ### `describe(name, fn)` -Jest Aligned +[![Jest](/img/jestjs.svg)](https://jest-archive-august-2023.netlify.app/docs/27.x/api#describename-fn) ![Aligned](/img/aligned.svg) `describe(name, fn)` creates a block that groups together several related tests. For example, if you have a `myBeverage` object that is supposed to be delicious but not sour, you could test it with: @@ -241,7 +241,7 @@ end) ``` ### `describe.each(table)(name, fn, timeout)` -Jest API change +[![Jest](/img/jestjs.svg)](https://jest-archive-august-2023.netlify.app/docs/27.x/api#describeeachtablename-fn-timeout) ![API Change](/img/apichange.svg) Use `describe.each` if you keep duplicating the same test suites with different data. `describe.each` allows you to write the test suite once and pass data in. @@ -300,7 +300,7 @@ end) ``` #### 2. `describe.each(...args)(name, fn, timeout)` -API change +![API Change](/img/apichange.svg) - `...args` - First argument is a string with headings separated by `|`, or a table with a single element containing that. @@ -334,7 +334,7 @@ end) ``` ### `describe.only(name, fn)` -Jest Aligned +[![Jest](/img/jestjs.svg)](https://jest-archive-august-2023.netlify.app/docs/27.x/api#describeonlyname-fn) ![Aligned](/img/aligned.svg) Also under the alias: `fdescribe(name, fn)` @@ -357,7 +357,7 @@ end) ``` ### `describe.only.each(table)(name, fn)` -Jest API change +[![Jest](/img/jestjs.svg)](https://jest-archive-august-2023.netlify.app/docs/27.x/api#describeonlyeachtablename-fn) ![API Change](/img/apichange.svg) Also under the aliases: `fdescribe.each(table)(name, fn)` and `` fdescribe.each`table`(name, fn) `` @@ -384,7 +384,7 @@ end) ``` #### `describe.only.each(...args)(name, fn)` -API change +![API Change](/img/apichange.svg) ```lua describe.only.each({'a | b | expected'}, @@ -405,7 +405,7 @@ end) ``` ### `describe.skip(name, fn)` -Jest Aligned +[![Jest](/img/jestjs.svg)](https://jest-archive-august-2023.netlify.app/docs/27.x/api#describeskipname-fn) ![Aligned](/img/aligned.svg) Also under the alias: `xdescribe(name, fn)` @@ -430,7 +430,7 @@ end) Using `describe.skip` is often a cleaner alternative to temporarily commenting out a chunk of tests. Beware that the `describe` block will still run. If you have some setup that also should be skipped, do it in a `beforeAll` or `beforeEach` block. ### `describe.skip.each(table)(name, fn)` -Jest API change +[![Jest](/img/jestjs.svg)](https://jest-archive-august-2023.netlify.app/docs/27.x/api#describeskipeachtablename-fn) ![API Change](/img/apichange.svg) Also under the aliases: `xdescribe.each(table)(name, fn)` and `xdescribe.each(...args)(name, fn)` @@ -457,7 +457,7 @@ end) ``` #### `describe.skip.each(...args)(name, fn)` -API change +![API Change](/img/apichange.svg) ```lua describe.skip.each({'a | b | expected'}, @@ -478,7 +478,7 @@ end) ``` ### `test(name, fn, timeout)` -Jest API change +[![Jest](/img/jestjs.svg)](https://jest-archive-august-2023.netlify.app/docs/27.x/api#testname-fn-timeout) ![API Change](/img/apichange.svg) Also under the alias: `it(name, fn, timeout)` @@ -512,7 +512,7 @@ end) Even though the call to `test` will return right away, the test doesn't complete until the promise resolves as well. ### `test.each(table)(name, fn, timeout)` -Jest API change +[![Jest](/img/jestjs.svg)](https://jest-archive-august-2023.netlify.app/docs/27.x/api#testeachtablename-fn-timeout) ![API Change](/img/apichange.svg) Also under the alias: `it.each(table)(name, fn)` and `it.each(...args)(name, fn)` @@ -551,7 +551,7 @@ end) ``` #### 2. `test.each(...args)(name, fn, timeout)` -API change +![API Change](/img/apichange.svg) - `...args` - First argument is a string with headings separated by `|`, or a table with a single element containing that. @@ -574,7 +574,7 @@ end) ``` ### `test.failing(name, fn, timeout)` -Jest Aligned +[![Jest](/img/jestjs.svg)](https://jest-archive-august-2023.netlify.app/docs/next/api#testfailingname-fn-timeout) ![Aligned](/img/aligned.svg) Also under the alias: `it.failing(name, fn, timeout)` @@ -603,7 +603,7 @@ end) ``` ### `test.only.failing(name, fn, timeout)` -Jest Aligned +[![Jest](/img/jestjs.svg)](https://jest-archive-august-2023.netlify.app/docs/next/api#testonlyfailingname-fn-timeout) ![Aligned](/img/aligned.svg) Also under the aliases: `it.only.failing(name, fn, timeout)` @@ -612,7 +612,7 @@ Also under the aliases: `it.only.failing(name, fn, timeout)` Use `test.only.failing` if you want to only run a specific failing test. ### `test.skip.failing(name, fn, timeout)` -Jest Aligned +[![Jest](/img/jestjs.svg)](https://jest-archive-august-2023.netlify.app/docs/next/api#testskipfailingname-fn-timeout) ![Aligned](/img/aligned.svg) Also under the aliases: `it.skip.failing(name, fn, timeout)` @@ -621,7 +621,7 @@ Also under the aliases: `it.skip.failing(name, fn, timeout)` Use `test.skip.failing` if you want to skip running a specific failing test. ### `test.only(name, fn, timeout)` -Jest Aligned +[![Jest](/img/jestjs.svg)](https://jest-archive-august-2023.netlify.app/docs/27.x/api#testonlyname-fn-timeout) ![Aligned](/img/aligned.svg) Also under the aliases: `it.only(name, fn, timeout)`, and `fit(name, fn, timeout)` @@ -646,7 +646,7 @@ Only the "it is raining" test will run in that test file, since it is run with ` Usually you wouldn't check code using `test.only` into source control - you would use it for debugging, and remove it once you have fixed the broken tests. ### `test.only.each(table)(name, fn)` -Jest API change +[![Jest](/img/jestjs.svg)](https://jest-archive-august-2023.netlify.app/docs/27.x/api#testonlyeachtablename-fn-1) ![API Change](/img/apichange.svg) Also under the aliases: `it.only.each(table)(name, fn)`, `fit.each(table)(name, fn)`, `` it.only.each`table`(name, fn) `` and `` fit.each`table`(name, fn) `` @@ -671,7 +671,7 @@ end) ``` #### `test.only.each(...args)(name, fn)` -API change +![API Change](/img/apichange.svg) ```lua test.only.each({'a | b | expected'}, @@ -689,7 +689,7 @@ end) ``` ### `test.skip(name, fn)` -Jest Aligned +[![Jest](/img/jestjs.svg)](https://jest-archive-august-2023.netlify.app/docs/27.x/api#testskipname-fn) ![Aligned](/img/aligned.svg) Also under the aliases: `it.skip(name, fn)`, `xit(name, fn)`, and `xtest(name, fn)` @@ -712,7 +712,7 @@ Only the "it is raining" test will run, since the other test is run with `test.s You could comment the test out, but it's often a bit nicer to use `test.skip` because it will maintain indentation and syntax highlighting. ### `test.skip.each(table)(name, fn)` -Jest API change +[![Jest](/img/jestjs.svg)](https://jest-archive-august-2023.netlify.app/docs/27.x/api#testskipeachtablename-fn) ![API Change](/img/apichange.svg) Also under the aliases: `it.skip.each(table)(name, fn)`, `xit.each(table)(name, fn)`, `xtest.each(table)(name, fn)`, `` it.skip.each`table`(name, fn) ``, `xit.each(..args)(name, fn) `` and `xtest.each(...args)(name, fn)` @@ -737,7 +737,7 @@ end) ``` #### `test.skip.each(...args)(name, fn)` -API change +![API Change](/img/apichange.svg) ```lua test.skip.each({'a | b | expected'}, @@ -755,7 +755,7 @@ end) ``` ### `test.todo(name)` -Jest Aligned +[![Jest](/img/jestjs.svg)](https://jest-archive-august-2023.netlify.app/docs/27.x/api#testtodoname) ![Aligned](/img/aligned.svg) Also under the alias: `it.todo(name)` diff --git a/docs/docs/GlobalMocks.md b/docs/docs/GlobalMocks.md new file mode 100644 index 00000000..d4e57198 --- /dev/null +++ b/docs/docs/GlobalMocks.md @@ -0,0 +1,109 @@ +--- +id: global-mocks +title: Global Mocks +--- + +Roblox only + +It can be desirable to track how an implementation interacts with Luau globals. +For example, you might want to test that a certain message is printed to the +console, or you might want to take control of the random number generator to get +a deterministic, predictable sequence of numbers. + +It isn't normally easy to mock these functions in the global Luau environment, +but Jest can replace their implementations for you, giving you a familiar +interface as if you're mocking any other regular function. + +:::warning + +Jest Roblox does not support mocking all globals - only a few are whitelisted. +If you try to mock a global which is not whitelisted, you will see an error +message that looks similar to this: + +``` +Jest does not yet support mocking the require global. +``` + +Most notably, Jest Roblox does not support mocking these globals: + +- `game:GetService()` and other Instance methods (an API for this is being investigated) +- the `require()` function (use [`jest.mock()`](jest-object) instead) +- task scheduling functions (use [Timer Mocks](timer-mocks) instead) + +::: + +## Mock a Global Function + +In the following example, we mock the global `print()` function so that our test +can see what's being printed. This is done as if you're spying on a table using +`jest.spyOn()`, except you use `jest.globalEnv` as the table. + +```lua title="limerick.lua" +return function() + print("There once was a print() in a test") + print("It would cause the maintainers unrest") + print("Printing all 'round the clock") + print("Beyond what they could mock") + print("Until globalEnv landed in Jest!") +end +``` + +```lua title="__tests__/limerick.spec.lua" +local jest = JestGlobals.jest + +test('mentions print() in the first line', function() + local limerick = require(Workspace.limerick) + + local mockPrint = jest.spyOn(jest.globalEnv, "print") + mockPrint.mockImplementationOnce(function(firstLine: string, ...: string) + expect(firstLine).toEqual(expect.stringContaining("print()")) + end) + + limerick() +end) +``` + +## Mocking Globals in Libraries + +You can mock functions in libraries like `math` by indexing into +`jest.globalEnv` with the name of the library. The following example shows how +to mock `math.random()` to return a predictable number. + +```lua title="diceRoll.lua" +return function() + return "You rolled a " .. math.random(1, 6) +end +``` + +```lua title="__tests__/diceRoll.spec.lua" +local jest = JestGlobals.jest + +test('correctly formats returned message', function() + local diceRoll = require(Workspace.diceRoll) + + local mockRandom = jest.spyOn(jest.globalEnv.math, "random") + mockRandom.mockImplementationOnce(function(_min: number?, _max: number?) + return 5 + end) + + expect(diceRoll()).toBe("You rolled a 5") +end) +``` + +## Use Original Implementation + +You can use the original (non-mocked) variant of the global function at any +time. Original implementations are available by indexing into `globalEnv` with +the name of the function you need access to. + +```lua +local mockRandom = jest.spyOn(jest.globalEnv.math, "random") +mockRandom.mockImplementation(function(_min: number?, _max: number?) + return 5 +end) + +-- will always be 5 +local mocked = math.random() +-- will be some random number between 0 and 1 +local unmocked = jest.globalEnv.math.random() +``` \ No newline at end of file diff --git a/docs/docs/JestBenchmarkAPI.md b/docs/docs/JestBenchmarkAPI.md index 65deefb2..0ec65666 100644 --- a/docs/docs/JestBenchmarkAPI.md +++ b/docs/docs/JestBenchmarkAPI.md @@ -1,66 +1,71 @@ --- id: jest-benchmark -title: JestBenchmark +title: Jest Benchmark --- -Roblox only -Benchmarks are useful tools for gating performance in CI, optimizing code, and capturing performance gains. JestBenchmark aims to make it easier to write benchmarks in the Luau language. +![Roblox only](/img/roblox-only.svg) -JestBenchmark must be imported from the JestBenchmark Package +Benchmarks are useful tools for gating performance in CI, optimizing code, and capturing performance gains. `JestBenchmark` aims to make it easier to write benchmarks in the Luau language. + +`JestBenchmark` must be added as a dev dependency to your `rotriever.toml` and imported. +```yaml title="rotriever.toml" +JestBenchmark = "3.9.1" +``` ```lua -local JestBenchmark = require("@DevPackages/JestBenchmark") +local JestBenchmark = require(Packages.Dev.JestBenchmark) local benchmark = JestBenchmark.benchmark local CustomReporters = JestBenchmark.CustomReporters ``` -### benchmark +## Methods + +import TOCInline from "@theme/TOCInline"; -Roblox only + + +### `benchmark(name, fn, timeout)` The `benchmark` function is a wrapper around `test` that provides automatic profiling for FPS and benchmark running time. Similar to `test`, it exposes `benchmark.only` and `benchmark.skip` to focus and skip tests, respectively. ```lua describe("Home Page Benchmarks", function() - benchmark("First Render Performance", function(Profiler, reporters) - render(React.createElement(HomePage)) + benchmark("First Render Performance", function(Profiler, reporters) + render(React.createElement(HomePage)) - local GameCarousel = screen.getByText("Continue"):expect() + local GameCarousel = screen.getByText("Continue"):expect() - expect(GameCarousel).toBeDefined() - end) + expect(GameCarousel).toBeDefined() + end) end) ``` -### Reporter -Roblox only +## Reporter The `Reporter` object collects and aggregates data generated during a benchmark. For example, you may have an FPS reporter that collects the delta time between each frame in a benchmark and calculates the average FPS over the benchmark. -### initializeReporter -Roblox only +### `initializeReporter(metricName, fn)` -`initializeReporter` accepts a metric name and collector function as arguments and returns a Reporter object. The metric name is the label given to the data collected. The collector function accepts a list of values and reduces them to a single value. +`initializeReporter` accepts a metric name and collector function as arguments and returns a `Reporter` object. The metric name is the label given to the data collected. The collector function accepts a list of values and reduces them to a single value. ```lua local function average(nums: { number }): num - if #nums == 0 then - return 0 - end + if #nums == 0 then + return 0 + end - local sum = 0 - for _, v in nums do - sum += v - end + local sum = 0 + for _, v in nums do + sum += v + end - return sum / #nums + return sum / #nums end local averageReporter = initializeReporter("average", average) ``` -### Reporter.start() -Roblox only +### `Reporter.start(sectionName)` A reporting segment is initialized with `Reporter.start(sectionName: string)`. All values reported within the segment are collected as a group and reduced to a single value in `Reporter.finish`. The segment is labeled with the `sectionName` argument. Reporter segments can be nested or can run sequentially. All Reporter segments must be concluded by calling `Reporter.stop` @@ -84,48 +89,42 @@ local sectionNames, sectionValues = averageReporter.finish() -- sectionValues: {2, 6, 4} ``` -### Reporter.stop() -Roblox only +### `Reporter.stop()` When `Reporter.stop` is called, the reporter section at the top of the stack is popped off, and a section of reported values are marked for collection at the end of benchmark. No collection is done during the benchmark runtime, since this could reduce performance. -### Reporter.report -Roblox only +### `Reporter.report(number)` When `Reporter.report(value: T)` is called, a value is added to the report queue. The values passed to report are reduced when `reporter.finish` is called. -### Reporter.finish -Roblox only +### `Reporter.finish()` -`Reporter.finish` should be called at the end of the benchmark runtime. It returns a list of section names and a list of section values generated according to the collectorFn. Values are returned in order of completion. +`Reporter.finish` should be called at the end of the benchmark runtime. It returns a list of section names and a list of section values generated according to the `collectorFn`. Values are returned in order of completion. -### Profiler -Roblox only +## Profiler The `Profiler` object controls a set of reporters and reports data generated during a benchmark. The Profiler is initialized with the `initializeProfiler` function. A profiling segment is started by calling `Profiler.start` and stopped by calling `Profiler.stop`. These segments can be called sequentially or can be nested. Results are generated by calling `Profiler.finish`. -### initializeProfiler -Roblox only +### `initializeProfiler(reporters, fn, prefix?)` -`intializeProfiler` accepts a list of reporters and an outputFn as arguments and returns a Profiler object. +`intializeProfiler` accepts a list of reporters and an outputFn as arguments and returns a `Profiler` object. An optional `prefix` string can be appended to all the section names. ```lua local reporters = { - initializeReporter("average", average), - initializeReporter("sectionTime", sectionTime), + initializeReporter("average", average), + initializeReporter("sectionTime", sectionTime), } local outputFn = function(metricName: string, value: any) - print(`{metricName}, {value}`) + print(`{metricName}, {value}`) end local profiler = initializeProfiler(reporters, outputFn) ``` -### Profiler.start -Roblox only +### `Profiler.start(sectionName)` -When `Profiler.start(sectionName: string)` is called, reporter.start is called for each reporter in the reporters list. Each Profiler section must be concluded with a `Profiler.stop()` call. +When `Profiler.start(sectionName: string)` is called, `reporter.start` is called for each reporter in the reporters list. Each Profiler section must be concluded with a `Profiler.stop()` call. ```lua Profiler.start("section1") @@ -133,60 +132,69 @@ Profiler.start("section1") Profiler.stop() ``` -### Profiler.stop -Roblox only +### `Profiler.stop()` When `Profiler.stop()` is called, reporter.stop is called for each reporter in the reporters list. Calling `Profiler.stop` without first calling `Profiler.start` will result in an error. -### Profiler.finish -Roblox only +### `Profiler.finish()` When `Profiler.finish` is called, reporter.finish is called for each reporter in the reporters list. The results of each finish call is then printed by the outputFn passed to the Profiler. -### CustomReporters -Roblox only +## CustomReporters -By default, the `benchmark` function has two reporters attached: FPS and SectionTime. However, you may want to add custom reporters, perhaps to track Rodux action dispatches, time to interactive, or React re-renders. To enable this, the CustomReporters object exports `useCustomReporters`, which allows the user to add additional reporters to the Profiler. These reporters are passed in a key-value table as the second argument in the provided benchmark function. This should be used in combination with `useDefaultReporters`, which removes all custom reporters from the Profiler. +By default, the `benchmark` function has two reporters attached: `FPSReporter` and `SectionTimeReporter`. However, you may want to add custom reporters, perhaps to track Rodux action dispatches, time to interactive, or React re-renders. To enable this, the CustomReporters object exports `useCustomReporters`, which allows the user to add additional reporters to the Profiler. These reporters are passed in a key-value table as the second argument in the provided benchmark function. This should be used in combination with `useDefaultReporters`, which removes all custom reporters from the Profiler. ```lua +local MetricLogger = JestBenchmarks.CustomReporters + beforeEach(function() - CustomReporters.useCustomReporters({ - sum = initializeReporter("sum", function(nums) - local sum = 0 - for _, v in nums do - sum += v - end - return sum - end) - }) + CustomReporters.useCustomReporters({ + sum = initializeReporter("sum", function(nums) + local sum = 0 + for _, v in nums do + sum += v + end + return sum + end) + }) end) benchmark("Total renders", function(Profiler, reporters) - local renderCount = getRenderCount() - reporters.sum.report(renderCount) + local renderCount = getRenderCount() + reporters.sum.report(renderCount) end) afterEach(function() - CustomReporters.useDefaultReporters() + CustomReporters.useDefaultReporters() end) ``` -### MetricLogger -Roblox only +## MetricLogger By default, benchmarks output directly to stdout. This may not be desirable in all cases. For example, you may want to output results to a BindableEvent or a file stream. The MetricLogger object exposes a `useCustomMetricLogger` function, which allows the user to override the default output function. This should be used in combination with `useDefaultMetricLogger`, which resets the output function to the default value +For example, to encode the benchmark metrics as a JSON and write the output to a `json` file for each test file, you may configure the following custom metric logger in a [`setupFilesAfterEnv`](configuration#setupfilesafterenv-arraymodulescript): ```lua +local MetricLogger = JestBenchmarks.MetricLogger + +local benchmarks + +beforeAll(function() + benchmarks = {} +end) + beforeEach(function() - MetricLogger.useCustomMetricLogger(function(metricName: string, value: any) - print(HttpService:JSONEncode({ - metric = metricName, - value = value - })) - end) + MetricLogger.useCustomMetricLogger(function(metricName: string, value: any) + table.insert(benchmarks, HttpService:JSONEncode({ + metric = metricName, + value = value + })) + end) end) -afterEach(function() - MetricLogger.useDefaultMetricLogger() +afterAll(function() + local benchmarkFile = tostring(expect.getState().testPath) .. ".json" + FileSystemService:WriteFile(benchmarkFile, benchmarks) + MetricLogger.useDefaultMetricLogger() end) ``` diff --git a/docs/docs/JestObjectAPI.md b/docs/docs/JestObjectAPI.md index 9d1e1898..d433063c 100644 --- a/docs/docs/JestObjectAPI.md +++ b/docs/docs/JestObjectAPI.md @@ -2,11 +2,11 @@ id: jest-object title: The Jest Object --- -

Jest

+[![Jest](/img/jestjs.svg)](https://jest-archive-august-2023.netlify.app/docs/27.x/jest-object) The methods in the `jest` object help create mocks and let you control Jest Lua's overall behavior. -deviation +![Deviation](/img/deviation.svg) It must be imported explicitly from `JestGlobals`. ```lua @@ -22,7 +22,7 @@ import TOCInline from "@theme/TOCInline"; ## Mock Modules ### `jest.mock(module, factory)` -Jest API change +[![Jest](/img/jestjs.svg)](https://jestjs.io/docs/jest-object#jestmockmodulename-factory-options) ![API Change](/img/apichange.svg) Mocks a module with an mocked version when it is being required. The second argument must be used to specify the value of the mocked module. ```lua title="mockedModule.lua" @@ -53,7 +53,7 @@ Modules that are mocked with `jest.mock` are mocked only for the file that calls Returns the `jest` object for chaining. ### `jest.unmock(module)` -Jest API change +[![Jest](/img/jestjs.svg)](https://jestjs.io/docs/jest-object#jestunmockmodulename) ![API Change](/img/apichange.svg) Indicates that the module system should never return a mocked version of the specified module from `require()` (e.g. that it should always return the real module). @@ -67,7 +67,7 @@ end) ``` ### `jest.requireActual(module)` -Jest API change +[![Jest](/img/jestjs.svg)](https://jestjs.io/docs/jest-object#jestrequireactualmodulename) ![API Change](/img/apichange.svg) Returns the actual module instead of a mock, bypassing all checks on whether the module should receive a mock implementation or not. @@ -79,7 +79,7 @@ end) ``` ### `jest.isolateModules(fn)` -Jest Aligned +[![Jest](/img/jestjs.svg)](https://jest-archive-august-2023.netlify.app/docs/27.x/jest-object#jestisolatemodulesfn) ![Aligned](/img/aligned.svg) `jest.isolateModules(fn)` creates a sandbox registry for the modules that are loaded inside the callback function. This is useful to isolate specific modules for every test so that local module state doesn't conflict between tests. @@ -93,7 +93,7 @@ local otherCopyOfMyModule = require(Workspace.MyModule) ``` ### `jest.resetModules()` -Jest Aligned +[![Jest](/img/jestjs.svg)](https://jest-archive-august-2023.netlify.app/docs/27.x/jest-object#jestresetmodules) ![Aligned](/img/aligned.svg) Resets the module registry - the cache of all required modules. This is useful to isolate modules where local state might conflict between tests. @@ -129,7 +129,7 @@ Returns the `jest` object for chaining. ## Mock Functions ### `jest.fn(implementation)` -Jest Deviation +[![Jest](/img/jestjs.svg)](https://jest-archive-august-2023.netlify.app/docs/27.x/jest-object#jestfnimplementation) ![Deviation](/img/deviation.svg) Returns a new, unused [mock function](mock-function-api). Optionally takes a mock implementation. @@ -145,8 +145,47 @@ local returnsTrue = jest.fn(function() return true end) print(returnsTrue()) -- true ``` +### `jest.spyOn(object, methodName)` +[![Jest](/img/jestjs.svg)](https://jest-archive-august-2023.netlify.app/docs/28.x/jest-object#jestspyonobject-methodname) ![Aligned](/img/aligned.svg) + +Creates a mock function similar to `jest.fn` but also tracks calls to `object[methodName]`. Returns a Jest [mock function](MockFunctionAPI.md). + +_Note: By default, `jest.spyOn` also calls the **spied** method. This is different behavior from most other test libraries. If you want to overwrite the original function, you can use `jest.spyOn(object, methodName):mockImplementation(function() ... end)` or `object[methodName] = jest.fn(function ... end)`_ + +Example: + +```lua +local video = { + play = function() + return true + end +} + +return video +``` + +Example test: + +```lua +local video = require(Workspace.video); + +test("plays video", function() + local spy = jest.spyOn(video, "play") + local isPlaying = video.play() + + expect(spy).toHaveBeenCalled() + expect(isPlaying).toBe(true) + + spy:mockRestore() +end) +``` + +:::info +The `jest.spyOn(object, methodName, accessType?)` variant is not currently supported in jest-roblox. +::: + ### `jest.clearAllMocks()` -Jest Aligned +[![Jest](/img/jestjs.svg)](https://jest-archive-august-2023.netlify.app/docs/27.x/jest-object#jestclearallmocks) ![Aligned](/img/aligned.svg) Clears the `mock.calls`, `mock.instances` and `mock.results` properties of all mocks. Equivalent to calling [`.mockClear()`](mock-function-api#mockfnmockclear) on every mocked function. @@ -155,14 +194,14 @@ This can be included in a `beforeEach()` block in your text fixture to clear out Returns the `jest` object for chaining. ### `jest.resetAllMocks()` -Jest Aligned +[![Jest](/img/jestjs.svg)](https://jest-archive-august-2023.netlify.app/docs/27.x/jest-object#jestresetallmocks) ![Aligned](/img/aligned.svg) Resets the state of all mocks. Equivalent to calling [`.mockReset()`](mock-function-api#mockfnmockreset) on every mocked function. Returns the `jest` object for chaining. ### `mockFn.mockImplementation(fn)` -Jest Deviation +[![Jest](/img/jestjs.svg)](https://jest-archive-august-2023.netlify.app/docs/27.x/mock-function-api#mockfnmockimplementationfn) ![Deviation](/img/deviation.svg) Accepts a function that should be used as the implementation of the mock. The mock itself will still record all calls that go into and instances that come from itself – the only difference is that the implementation will also be executed when the mock is called. @@ -153,7 +153,7 @@ Mocks should be lightweight and easy to maintain and/or refactor, so users shoul ::: ### `mockFn.mockImplementationOnce(fn)` -Jest Aligned +[![Jest](/img/jestjs.svg)](https://jest-archive-august-2023.netlify.app/docs/27.x/mock-function-api#mockfnmockimplementationoncefn) ![Aligned](/img/aligned.svg) Accepts a function that will be used as an implementation of the mock for one call to the mocked function. Can be chained so that multiple function calls produce different results. @@ -180,7 +180,7 @@ print(myMockFn()) -- 'default ``` ### `mockFn.mockName(value)` -Jest Aligned +[![Jest](/img/jestjs.svg)](https://jest-archive-august-2023.netlify.app/docs/27.x/mock-function-api#mockfnmocknamevalue) ![Aligned](/img/aligned.svg) Accepts a string to use in test result output in place of "jest.fn()" to indicate which mock function is being referenced. @@ -202,12 +202,12 @@ Received number of calls: 0 ``` ### `mockFn.mockReturnThis()` -Jest Aligned +[![Jest](/img/jestjs.svg)](https://jest-archive-august-2023.netlify.app/docs/27.x/mock-function-api#mockfnmockreturnthis) ![Aligned](/img/aligned.svg) Sets the implementation of `mockFn` to return itself whenenever the mock function is called. ### `mockFn.mockReturnValue(value)` -Jest Aligned +[![Jest](/img/jestjs.svg)](https://jest-archive-august-2023.netlify.app/docs/27.x/mock-function-api#mockfnmockreturnvaluevalue) ![Aligned](/img/aligned.svg) Accepts a value that will be returned whenever the mock function is called. @@ -220,7 +220,7 @@ mock() -- 43 ``` ### `mockFn.mockReturnValueOnce(value)` -Jest Aligned +[![Jest](/img/jestjs.svg)](https://jest-archive-august-2023.netlify.app/docs/27.x/mock-function-api#mockfnmockreturnvalueoncevalue) ![Aligned](/img/aligned.svg) Accepts a value that will be returned for one call to the mock function. Can be chained so that successive calls to the mock function return different values. When there are no more `mockReturnValueOnce` values to use, calls will return a value specified by `mockReturnValue`. diff --git a/docs/docs/MockFunctions.md b/docs/docs/MockFunctions.md index eccdf191..ab1561f2 100644 --- a/docs/docs/MockFunctions.md +++ b/docs/docs/MockFunctions.md @@ -2,7 +2,7 @@ id: mock-functions title: Mock Functions --- -

Jest

+[![Jest](/img/jestjs.svg)](https://jest-archive-august-2023.netlify.app/docs/27.x/mock-functions) Mock functions allow you to test the links between code by erasing the actual implementation of a function, capturing calls to the function (and the parameters passed in those calls), capturing instances of constructor functions when instantiated with `new`, and allowing test-time configuration of return values. @@ -193,4 +193,4 @@ expect(mockFunc.mock.calls[#mockFunc.mock.calls]).toEqual({ expect(mockFunc.mock.calls[#mockFunc.mock.calls][1]).toBe(42) ``` -For a complete list of matchers, check out the [reference docs](expect). \ No newline at end of file +For a complete list of matchers, check out the [reference docs](expect). diff --git a/docs/docs/SetupAndTeardown.md b/docs/docs/SetupAndTeardown.md index 7fac8a2f..a68c2bcb 100644 --- a/docs/docs/SetupAndTeardown.md +++ b/docs/docs/SetupAndTeardown.md @@ -2,7 +2,7 @@ id: setup-teardown title: Setup and Teardown --- -

Jest

+[![Jest](/img/jestjs.svg)](https://jest-archive-august-2023.netlify.app/docs/27.x/setup-teardown) Often while writing tests you have some setup work that needs to happen before tests run, and you have some finishing work that needs to happen after tests run. Jest Lua provides helper functions to handle this. @@ -222,4 +222,4 @@ test('this test will not run', function() end) ``` -If you have a test that often fails when it's run as part of a larger suite, but doesn't fail when you run it alone, it's a good bet that something from a different test is interfering with this one. You can often fix this by clearing some shared state with `beforeEach`. If you're not sure whether some shared state is being modified, you can also try a `beforeEach` that logs data. \ No newline at end of file +If you have a test that often fails when it's run as part of a larger suite, but doesn't fail when you run it alone, it's a good bet that something from a different test is interfering with this one. You can often fix this by clearing some shared state with `beforeEach`. If you're not sure whether some shared state is being modified, you can also try a `beforeEach` that logs data. diff --git a/docs/docs/SnapshotTesting.md b/docs/docs/SnapshotTesting.md index 176e1af2..165712bf 100644 --- a/docs/docs/SnapshotTesting.md +++ b/docs/docs/SnapshotTesting.md @@ -2,7 +2,7 @@ id: snapshot-testing title: Snapshot Testing --- -

Jest

+[![Jest](/img/jestjs.svg)](https://jest-archive-august-2023.netlify.app/docs/27.x/snapshot-testing) Snapshot tests are a very useful tool whenever you want to make sure your UI does not change unexpectedly. @@ -91,7 +91,7 @@ runCLI(Project, { You'll also need to pass the following flags to give `roblox-cli` the proper permissions to update snapshots: ``` ---load.asRobloxScript --fs.readwrite="$(pwd)" +--load.asRobloxScript --fs.readwrite="$(pwd)" ``` :::tip @@ -268,4 +268,4 @@ Although it is possible to write snapshot files manually, that is usually not ap ### Does code coverage work with snapshot testing? -Yes, as well as with any other test. \ No newline at end of file +Yes, as well as with any other test. diff --git a/docs/docs/TestingAsyncCode.md b/docs/docs/TestingAsyncCode.md index 5f02c29a..ed220f90 100644 --- a/docs/docs/TestingAsyncCode.md +++ b/docs/docs/TestingAsyncCode.md @@ -2,7 +2,7 @@ id: asynchronous title: Testing Asynchronous Code --- -

Jest

+[![Jest](/img/jestjs.svg)](https://jest-archive-august-2023.netlify.app/docs/27.x/asynchronous) It's common in Lua for code to run asynchronously. When you have code that runs asynchronously, Jest Lua needs to know when the code it is testing has completed, before it can move on to another test. Jest Lua has several ways to handle this. @@ -75,4 +75,4 @@ If the `expect` statement fails, it throws an error and `done()` is not called. :::danger `done()` should not be mixed with Promises in your tests. -::: \ No newline at end of file +::: diff --git a/docs/docs/TimerMocks.md b/docs/docs/TimerMocks.md index c40c90af..2f1e8653 100644 --- a/docs/docs/TimerMocks.md +++ b/docs/docs/TimerMocks.md @@ -2,9 +2,9 @@ id: timer-mocks title: Timer Mocks --- -

Jest

+[![Jest](/img/jestjs.svg)](https://jest-archive-august-2023.netlify.app/docs/27.x/timer-mocks) -deviation +![Deviation](/img/deviation.svg) The Lua and Roblox native timer functions (i.e., `delay()`, `tick()`, `os.time()`, `os.clock()`) are less than ideal for a testing environment since they depend on real time to elapse. Jest Lua can swap out timers with functions that allow you to control the passage of time. [Great Scott!](https://www.youtube.com/watch?v=QZoJ2Pt27BY) @@ -113,7 +113,7 @@ end) ``` ## Advance Timers by Time -deviation +![Deviation](/img/deviation.svg) Another possibility is use `jest.advanceTimersByTime(secsToRun)`. When this API is called, all timers are advanced by `secsToRun` seconds. All pending "macro-tasks" that have been queued, and would be executed during this time frame, will be executed. Additionally, if those macro-tasks schedule new macro-tasks that would be executed within the same time frame, those will be executed until there are no more macro-tasks remaining in the queue that should be run within `secsToRun` seconds. @@ -153,7 +153,7 @@ end) Lastly, it may occasionally be useful in some tests to be able to clear all of the pending timers. For this, we have `jest.clearAllTimers()`. ## Setting Engine Frame Time -Roblox only +![Roblox only](/img/roblox-only.svg) By default, Jest Lua processes fake timers in continuous time. However, because the Roblox engine processes timers only once per frame, this may not accurately reflect engine behavior. diff --git a/docs/docs/UsingMatchers.md b/docs/docs/UsingMatchers.md index 4c22a33c..70f770f2 100644 --- a/docs/docs/UsingMatchers.md +++ b/docs/docs/UsingMatchers.md @@ -40,7 +40,7 @@ end) ``` ## Truthiness -Deviation +![Deviation](/img/deviation.svg) In tests, you sometimes need to distinguish between `nil`, and `false`, but you sometimes do not want to treat these differently. Jest Lua contains helpers that let you be explicit about what you want. @@ -101,7 +101,7 @@ end) ``` ## Strings -API change +![API Change](/img/apichange.svg) You can check strings against [Lua string patterns](https://developer.roblox.com/en-us/articles/string-patterns-reference) with `toMatch`: @@ -199,7 +199,7 @@ end) ``` ## Exceptions -API change +![API Change](/img/apichange.svg) If you want to test whether a particular function throws an error when it's called, use `toThrow`. diff --git a/docs/docusaurus.config.js b/docs/docusaurus.config.js index 9bb89d49..3d7057c7 100644 --- a/docs/docusaurus.config.js +++ b/docs/docusaurus.config.js @@ -1,6 +1,6 @@ /** @type {import('@docusaurus/types').DocusaurusConfig} */ -const VERSION = '3.6.1-rc.2'; +const VERSION = '3.10.0'; module.exports = { title: 'Jest Lua', @@ -12,6 +12,8 @@ module.exports = { onBrokenLinks: 'throw', onBrokenMarkdownLinks: 'warn', favicon: 'img/favicon.svg', + staticDirectories: ['static'], + trailingSlash: false, themeConfig: { navbar: { title: `Jest Lua v${VERSION}`, diff --git a/docs/sidebars.js b/docs/sidebars.js index 3907795a..1ff992ae 100644 --- a/docs/sidebars.js +++ b/docs/sidebars.js @@ -17,6 +17,7 @@ module.exports = { items: [ 'snapshot-testing', 'timer-mocks', + 'global-mocks', 'testez-migration', 'upgrading-to-jest3', ] @@ -28,6 +29,7 @@ module.exports = { 'expect', 'mock-function-api', 'jest-object', + 'jest-benchmark', 'configuration', 'cli', ] diff --git a/foreman.toml b/foreman.toml index f26e3233..0f7d000d 100644 --- a/foreman.toml +++ b/foreman.toml @@ -5,4 +5,4 @@ stylua = { source = "JohnnyMorganz/StyLua", version = "=0.18.1" } wally = { github = "UpliftGames/wally", version = "=0.3.2" } luau-lsp = { github = "johnnymorganz/luau-lsp", version = "=1.27.1"} run-in-roblox = { github = "rojo-rbx/run-in-roblox", version = "=0.3.0" } -darklua = { github = "seaofvoices/darklua", version = "=0.12.1" } +darklua = { github = "seaofvoices/darklua", version = "=0.14.0" } diff --git a/package.json b/package.json index bb4c5f4b..059b631b 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,8 @@ "style-check": "stylua src --check", "test:roblox": "sh ./scripts/test-roblox.sh", "verify-pack": "yarn workspaces foreach -A --no-private pack --dry-run", - "clean": "rm -rf roblox build node_modules" + "clean": "rm -rf roblox build node_modules", + "test": "sh ./scripts/test-roblox.sh" }, "devDependencies": { "commander": "^11.1.0", diff --git a/roblox-model/JestMockGenv.lua b/roblox-model/JestMockGenv.lua new file mode 100644 index 00000000..4edfdcdb --- /dev/null +++ b/roblox-model/JestMockGenv.lua @@ -0,0 +1 @@ +return require('@pkg/@jsdotlua/jest-mock-genv') diff --git a/roblox-model/JestMockRbx.lua b/roblox-model/JestMockRbx.lua new file mode 100644 index 00000000..31ebbc5e --- /dev/null +++ b/roblox-model/JestMockRbx.lua @@ -0,0 +1 @@ +return require("@pkg/@jsdotlua/jest-mock-rbx") diff --git a/scripts/test-roblox.sh b/scripts/test-roblox.sh index d3f07a2a..1b15d6d1 100755 --- a/scripts/test-roblox.sh +++ b/scripts/test-roblox.sh @@ -10,8 +10,8 @@ rm -f $OUTPUT mkdir -p roblox -cp -rL node_modules/ roblox/ -cp -r roblox-model/ roblox/ +cp -rL node_modules/ roblox/node_modules +cp -R roblox-model/ roblox/roblox-model mkdir roblox/scripts cp scripts/run-roblox-tests.lua roblox/scripts/ cp scripts/jest-setup.lua roblox/scripts/ diff --git a/src/diff-sequences/README.md b/src/diff-sequences/README.md index ce1c75fb..3b1f0cf8 100644 --- a/src/diff-sequences/README.md +++ b/src/diff-sequences/README.md @@ -1,10 +1,8 @@ # diff-sequences -Status: :heavy_check_mark: Ported +Upstream: https://github.com/facebook/jest/tree/v27.4.7/packages/diff-sequences -Source: https://github.com/facebook/jest/tree/v27.4.7/packages/diff-sequences - -Version: v27.4.7 +Compare items in two sequences to find a longest common subsequence. --- @@ -16,13 +14,3 @@ Version: v27.4.7 * Lua is 1 indexed so array indices are replaced with `index + 1`. * Uses of `NOT_YET_SET` are replaced with just a 0 since this is a JS-specific workaround. * Lua treats `0` as a true value so `nChange || baDeltaLength` needs to be written as `nChange ~= 0 and nChange or baDeltaLength`. - -### :x: Excluded -``` -perf -src/index.property.test.ts -``` - -### :package: [Dependencies](https://github.com/facebook/jest/blob/v27.4.7/packages/diff-sequences/package.json) -| Package | Version | Status | Notes | -| ------- | ------- | ------ | ----- | diff --git a/src/emittery/README.md b/src/emittery/README.md index 5705ab20..78547d83 100644 --- a/src/emittery/README.md +++ b/src/emittery/README.md @@ -1,19 +1,7 @@ # emittery -Status: :hammer: In Progress - -Source: https://github.com/sindresorhus/emittery/tree/v0.11.0 - -Version: v0.11.0 +Upstream: https://github.com/sindresorhus/emittery/tree/v0.11.0 --- ### :pencil2: Notes - -### :x: Excluded -``` -``` - -### :package: [Dependencies](https://github.com/sindresorhus/emittery/tree/v0.11.0/package.json) -| Package | Version | Status | Notes | -| ------- | ------- | ------ | ----- | diff --git a/src/expect/README.md b/src/expect/README.md index 9ba7cadf..806cbe91 100644 --- a/src/expect/README.md +++ b/src/expect/README.md @@ -1,10 +1,8 @@ # expect -Status: :hammer: In Progress +Upstream: https://github.com/facebook/jest/tree/v27.4.7/packages/expect -Source: https://github.com/facebook/jest/tree/v27.4.7/packages/expect - -Version: v27.4.7 +This package exports the `expect` function used in Jest. You can find its documentation in the [Jest documentation](https://roblox.github.io/jest-roblox-internal). --- @@ -41,15 +39,3 @@ Version: v27.4.7 * tables with a `message` key that has a string value * objects with a `__tostring` metamethod * :warning: Currently, the spyMatchers have undefined behavior when used with jest-mock and function calls with `nil` arguments, this should be fixed by ADO-1395 (the matchers may work incidentally but there are no guarantees) - -### :x: Excluded -``` -``` - -### :package: [Dependencies](https://github.com/facebook/jest/blob/v27.4.7/packages/expect/package.json) -| Package | Version | Status | Notes | -| -------------------- | ------- | ------------------------- | -------------------------------------------- | -| `@jest/types` | 27.4.2 | :heavy_check_mark: Ported | | -| `jest-get-type` | 27.4.0 | :heavy_check_mark: Ported | | -| `jest-matcher-utils` | 27.4.6 | :heavy_check_mark: Ported | | -| `jest-message-util` | 27.4.6 | :hammer: In Progress | Used for filtering stacktraces, low priority | diff --git a/src/jest-benchmark/README.md b/src/jest-benchmark/README.md index 798294e0..f17832db 100644 --- a/src/jest-benchmark/README.md +++ b/src/jest-benchmark/README.md @@ -1,7 +1,5 @@ # jest-benchmark -Status: :hammer: In Progress +*No upstream. Roblox only.* -Source: no upstream, roblox-only - -Version: v0.0.1 +This package exports a collection of Luau utilities that can be used alongside Jest to provide structure for writing benchmarks. You can find its documentation in the [Jest documentation](https://roblox.github.io/jest-roblox-internal/jest-benchmark). diff --git a/src/jest-benchmark/src/benchmark.lua b/src/jest-benchmark/src/benchmark.lua index 386b1f1b..879c312f 100644 --- a/src/jest-benchmark/src/benchmark.lua +++ b/src/jest-benchmark/src/benchmark.lua @@ -68,9 +68,6 @@ local function wrapBenchFnInProfiler(testName: Circus_TestName, benchFn: BenchFn Profiler.stop() Profiler.finish() - - -- Force gc step - task.wait() end end diff --git a/src/jest-circus/README.md b/src/jest-circus/README.md index a05c729b..9093fd91 100644 --- a/src/jest-circus/README.md +++ b/src/jest-circus/README.md @@ -1,40 +1,7 @@ # jest-circus -Status: :hammer: In Progress - -Source: https://github.com/facebook/jest/tree/v27.4.7/packages/jest-circus - -Version: v27.4.7 +Upstream: https://github.com/facebook/jest/tree/v27.4.7/packages/jest-circus --- ### :pencil2: Notes - -### :x: Excluded - - -### :package: [Dependencies](https://github.com/facebook/jest/blob/v27.4.7/packages/jest-circus/package.json) - -| Package | Version | Status | Notes | -| ------------------------------ | ------- | ------------------------- | ------------------------------------ | -| `@jest/environment` | ^27.4.6 | :heavy_check_mark: Ported | | -| `@jest/test-result` | ^27.4.6 | :heavy_check_mark: Ported | | -| `@jest/types` | ^27.4.2 | :heavy_check_mark: Ported | | -| `@types/node` | * | :x: Will not port | | -| `chalk` | ^4.0.0 | :heavy_check_mark: Ported | | -| `co` | ^4.6.0 | :x: Will not port | | -| `dedent` | ^0.7.0 | :x: Will not port | Using implementation from graphql-js | -| `expect` | ^27.4.6 | :heavy_check_mark: Ported | | -| `is-generator-fn` | ^2.0.0 | :x: Will not port | | -| `jest-each` | ^27.4.6 | :hammer: In Progress | | -| `jest-matcher-utils` | ^27.4.6 | :heavy_check_mark: Ported | | -| `jest-message-util` | ^27.4.6 | :heavy_check_mark: Ported | | -| `jest-runtime` | ^27.4.6 | | | -| `jest-snapshot` | ^27.4.6 | :heavy_check_mark: Ported | | -| `jest-util` | ^27.4.2 | :hammer: In Progress | | -| `pretty-format` | ^27.4.6 | :heavy_check_mark: Ported | | -| `slash` | ^3.0.0 | :x: Will not port | | -| `stack-utils` | ^2.0.3 | :x: Will not port | | -| `throat` | ^6.0.1 | :x: Will not port | | -| `jest-snapshot-serializer-raw` | 1.2.0 | :heavy_check_mark: Ported | | - diff --git a/src/jest-circus/src/circus/__tests__/__snapshots__/baseTest.spec.snap.lua b/src/jest-circus/src/circus/__tests__/__snapshots__/baseTest.spec.snap.lua index 5f9355c1..00b495f4 100644 --- a/src/jest-circus/src/circus/__tests__/__snapshots__/baseTest.spec.snap.lua +++ b/src/jest-circus/src/circus/__tests__/__snapshots__/baseTest.spec.snap.lua @@ -1,8 +1,5 @@ --- ROBLOX upstream: https://github.com/facebook/jest/blob/v28.0.0/packages/jest-circus/src/__tests__/__snapshots__/baseTest.test.ts.snap --- Jest Snapshot v1, https://goo.gl/fbAQLP - +-- Jest Roblox Snapshot v1, http://roblox.github.io/jest-roblox-internal/snapshot-testing local exports = {} - exports[ [=[failures 1]=] ] = [=[ "start_describe_definition: describe @@ -37,25 +34,6 @@ run_finish unhandledErrors: 0" ]=] -exports[ [=[function descriptors 1]=] ] = [=[ - -"start_describe_definition: describer -add_test: One -finish_describe_definition: describer -run_start -run_describe_start: ROOT_DESCRIBE_BLOCK -run_describe_start: describer -test_start: One -test_fn_start: One -test_fn_success: One -test_done: One -run_describe_finish: describer -run_describe_finish: ROOT_DESCRIBE_BLOCK -run_finish - -unhandledErrors: 0" -]=] - exports[ [=[simple test 1]=] ] = [=[ "start_describe_definition: describe diff --git a/src/jest-circus/src/circus/__tests__/__snapshots__/errorParsing.roblox.spec.snap.lua b/src/jest-circus/src/circus/__tests__/__snapshots__/errorParsing.roblox.spec.snap.lua index 79166d72..84371858 100644 --- a/src/jest-circus/src/circus/__tests__/__snapshots__/errorParsing.roblox.spec.snap.lua +++ b/src/jest-circus/src/circus/__tests__/__snapshots__/errorParsing.roblox.spec.snap.lua @@ -5,12 +5,10 @@ exports[ [=[formats a string error into proper output with message 1]=] ] = [=[ Table { "thrown: \"something went wrong!!\" Error -ReplicatedStorage.node_modules.@jsdotlua.jest-circus.src.circus.utils:553 function _getError -ReplicatedStorage.node_modules.@jsdotlua.collections.src.Array.map:34 -ReplicatedStorage.node_modules.@jsdotlua.jest-circus.src.circus.utils:441 function makeRunResult -ReplicatedStorage.node_modules.@jsdotlua.jest-circus.src.circus.__tests__.errorParsing.roblox.spec:52 -ReplicatedStorage.node_modules.@jsdotlua.jest-each.src.bind:168 -ReplicatedStorage.node_modules.@jsdotlua.jest-circus.src.circus.utils:367 +Redacted.Stack.Trace:1337 function epicDuck +Redacted.Stack.Trace:1337 function epicDuck +Redacted.Stack.Trace:1337 function epicDuck +Redacted.Stack.Trace:1337 function epicDuck ", } ]=] @@ -19,12 +17,10 @@ exports[ [=[formats an error object into proper output with message 1]=] ] = [=[ Table { "Error -ReplicatedStorage.node_modules.@jsdotlua.jest-circus.src.circus.__tests__.errorParsing.roblox.spec:39 -ReplicatedStorage.node_modules.@jsdotlua.jest-runtime.src:2038 function _execModule -ReplicatedStorage.node_modules.@jsdotlua.jest-runtime.src:1436 function _loadModule -ReplicatedStorage.node_modules.@jsdotlua.jest-runtime.src:1278 -ReplicatedStorage.node_modules.@jsdotlua.jest-runtime.src:1277 function requireModule -ReplicatedStorage.node_modules.@jsdotlua.jest-circus.src.circus.legacy-code-todo-rewrite.jestAdapter:113 +Redacted.Stack.Trace:1337 function epicDuck +Redacted.Stack.Trace:1337 function epicDuck +Redacted.Stack.Trace:1337 function epicDuck +Redacted.Stack.Trace:1337 function epicDuck ", } ]=] @@ -33,12 +29,10 @@ exports[ [=[formats an error object with a message into proper output with messa Table { "Error: something went wrong!! -ReplicatedStorage.node_modules.@jsdotlua.jest-circus.src.circus.__tests__.errorParsing.roblox.spec:40 -ReplicatedStorage.node_modules.@jsdotlua.jest-runtime.src:2038 function _execModule -ReplicatedStorage.node_modules.@jsdotlua.jest-runtime.src:1436 function _loadModule -ReplicatedStorage.node_modules.@jsdotlua.jest-runtime.src:1278 -ReplicatedStorage.node_modules.@jsdotlua.jest-runtime.src:1277 function requireModule -ReplicatedStorage.node_modules.@jsdotlua.jest-circus.src.circus.legacy-code-todo-rewrite.jestAdapter:113 +Redacted.Stack.Trace:1337 function epicDuck +Redacted.Stack.Trace:1337 function epicDuck +Redacted.Stack.Trace:1337 function epicDuck +Redacted.Stack.Trace:1337 function epicDuck ", } ]=] @@ -47,12 +41,10 @@ exports[ [=[formats an error object with a stack and message into proper output Table { "something went wrong!! -ReplicatedStorage.node_modules.@jsdotlua.jest-circus.src.circus.__tests__.errorParsing.roblox.spec:43 -ReplicatedStorage.node_modules.@jsdotlua.jest-runtime.src:2038 function _execModule -ReplicatedStorage.node_modules.@jsdotlua.jest-runtime.src:1436 function _loadModule -ReplicatedStorage.node_modules.@jsdotlua.jest-runtime.src:1278 -ReplicatedStorage.node_modules.@jsdotlua.jest-runtime.src:1277 function requireModule -ReplicatedStorage.node_modules.@jsdotlua.jest-circus.src.circus.legacy-code-todo-rewrite.jestAdapter:113 +Redacted.Stack.Trace:1337 function epicDuck +Redacted.Stack.Trace:1337 function epicDuck +Redacted.Stack.Trace:1337 function epicDuck +Redacted.Stack.Trace:1337 function epicDuck ", } ]=] @@ -61,12 +53,10 @@ exports[ [=[formats an error object with only a stack into proper output with me Table { "Error: something went wrong!! -ReplicatedStorage.node_modules.@jsdotlua.jest-circus.src.circus.__tests__.errorParsing.roblox.spec:47 -ReplicatedStorage.node_modules.@jsdotlua.jest-runtime.src:2038 function _execModule -ReplicatedStorage.node_modules.@jsdotlua.jest-runtime.src:1436 function _loadModule -ReplicatedStorage.node_modules.@jsdotlua.jest-runtime.src:1278 -ReplicatedStorage.node_modules.@jsdotlua.jest-runtime.src:1277 function requireModule -ReplicatedStorage.node_modules.@jsdotlua.jest-circus.src.circus.legacy-code-todo-rewrite.jestAdapter:113 +Redacted.Stack.Trace:1337 function epicDuck +Redacted.Stack.Trace:1337 function epicDuck +Redacted.Stack.Trace:1337 function epicDuck +Redacted.Stack.Trace:1337 function epicDuck ", } ]=] diff --git a/src/jest-circus/src/circus/__tests__/afterAll.spec.lua b/src/jest-circus/src/circus/__tests__/afterAll.spec.lua index 66c5ebb8..35a58d03 100644 --- a/src/jest-circus/src/circus/__tests__/afterAll.spec.lua +++ b/src/jest-circus/src/circus/__tests__/afterAll.spec.lua @@ -12,6 +12,12 @@ local JestGlobals = require("@pkg/@jsdotlua/jest-globals") local expect = JestGlobals.expect local it = JestGlobals.it +-- ROBLOX deviation: these tests require loadmodule +local loadModuleEnabled = pcall((debug :: any).loadmodule, Instance.new("ModuleScript")) +if not loadModuleEnabled then + it = it.skip :: any +end + it("tests are not marked done until their parent afterAll runs", function() local stdout = runTest([[ describe("describe", function() diff --git a/src/jest-circus/src/circus/__tests__/baseTest.spec.lua b/src/jest-circus/src/circus/__tests__/baseTest.spec.lua index 6bf69c43..76a8e369 100644 --- a/src/jest-circus/src/circus/__tests__/baseTest.spec.lua +++ b/src/jest-circus/src/circus/__tests__/baseTest.spec.lua @@ -12,6 +12,12 @@ local JestGlobals = require("@pkg/@jsdotlua/jest-globals") local expect = JestGlobals.expect local test = JestGlobals.test +-- ROBLOX deviation: these tests require loadmodule +local loadModuleEnabled = pcall((debug :: any).loadmodule, Instance.new("ModuleScript")) +if not loadModuleEnabled then + test = test.skip :: any +end + test("simple test", function() local stdout = runTest([[ describe("describe", function() @@ -25,15 +31,15 @@ test("simple test", function() end) -- ROBLOX deviation START: see if we can make this work later -- test("function descriptors", function() -test.skip("function descriptors", function() - -- ROBLOX deviation END - local stdout = runTest([[ - describe(function describer() end, function() - test(class One {}, function() end); - end) - ]]).stdout - expect(stdout).toMatchSnapshot() -end) +-- test.skip("function descriptors", function() +-- -- ROBLOX deviation END +-- local stdout = runTest([[ +-- describe(function describer() end, function() +-- test(class One {}, function() end); +-- end) +-- ]]).stdout +-- expect(stdout).toMatchSnapshot() +-- end) test("failures", function() local stdout = runTest([[ describe("describe", function() diff --git a/src/jest-circus/src/circus/__tests__/errorParsing.roblox.spec.lua b/src/jest-circus/src/circus/__tests__/errorParsing.roblox.spec.lua index 33cd0f9a..6a3966de 100644 --- a/src/jest-circus/src/circus/__tests__/errorParsing.roblox.spec.lua +++ b/src/jest-circus/src/circus/__tests__/errorParsing.roblox.spec.lua @@ -6,6 +6,7 @@ type Circus_DescribeBlock = typesModule.Circus_DescribeBlock local RobloxShared = require("@pkg/@jsdotlua/jest-roblox-shared") local pruneDeps = RobloxShared.pruneDeps +local redactStackTrace = RobloxShared.redactStackTrace local JestGlobals = require("@pkg/@jsdotlua/jest-globals") local expect = JestGlobals.expect @@ -47,12 +48,11 @@ local errors: { { name: string, err: LuauPolyfill.Error } } = { err = errorWithStack("Error: something went wrong!!\n" .. debug.traceback()), }, }; - (it.each :: FIXME_ANALYZE)(errors)("formats $name into proper output with message", function(errorData) local result = utils.makeRunResult(mockDescribeBlock(), { errorData.err }) local prunedErrors = {} for _, err in result.unhandledErrors do - table.insert(prunedErrors, pruneDeps(err)) + table.insert(prunedErrors, redactStackTrace(pruneDeps(err))) end expect(prunedErrors).toMatchSnapshot() diff --git a/src/jest-circus/src/circus/__tests__/formatNodeAssertErrors.roblox.spec.lua b/src/jest-circus/src/circus/__tests__/formatNodeAssertErrors.roblox.spec.lua index 452ea88f..8bac69c2 100644 --- a/src/jest-circus/src/circus/__tests__/formatNodeAssertErrors.roblox.spec.lua +++ b/src/jest-circus/src/circus/__tests__/formatNodeAssertErrors.roblox.spec.lua @@ -46,6 +46,7 @@ describe("formatNodeAssertErrors", function() parent = {} :: any, seenDone = false, } + assertionError.stack = pruneDeps(assertionError.stack) formatNodeAssertErrors(nil, { name = "test_done", test = test, diff --git a/src/jest-circus/src/circus/__tests__/hooks.spec.lua b/src/jest-circus/src/circus/__tests__/hooks.spec.lua index 34aceda9..72b209d1 100644 --- a/src/jest-circus/src/circus/__tests__/hooks.spec.lua +++ b/src/jest-circus/src/circus/__tests__/hooks.spec.lua @@ -12,6 +12,12 @@ local JestGlobals = require("@pkg/@jsdotlua/jest-globals") local expect = JestGlobals.expect local it = JestGlobals.it +-- ROBLOX deviation: these tests require loadmodule +local loadModuleEnabled = pcall((debug :: any).loadmodule, Instance.new("ModuleScript")) +if not loadModuleEnabled then + it = it.skip :: any +end + it("beforeEach is executed before each test in current/child describe blocks", function() local stdout = runTest([[ describe("describe", function() diff --git a/src/jest-circus/src/circus/formatNodeAssertErrors.lua b/src/jest-circus/src/circus/formatNodeAssertErrors.lua index 45b3ab5b..d4bec0be 100644 --- a/src/jest-circus/src/circus/formatNodeAssertErrors.lua +++ b/src/jest-circus/src/circus/formatNodeAssertErrors.lua @@ -16,6 +16,7 @@ local RobloxShared = require("@pkg/@jsdotlua/jest-roblox-shared") local escapePatternCharacters = RobloxShared.escapePatternCharacters local normalizePromiseError = RobloxShared.normalizePromiseError local Error = LuauPolyfill.Error +local cleanLoadStringStack = RobloxShared.cleanLoadStringStack -- ROBLOX deviation END type Record = { [K]: T } diff --git a/src/jest-config/README.md b/src/jest-config/README.md index e0ebd888..6cead5ac 100644 --- a/src/jest-config/README.md +++ b/src/jest-config/README.md @@ -1,10 +1,6 @@ # jest-config -Status: :hammer: In Progress - -Source: https://github.com/facebook/jest/tree/v27.4.7/packages/jest-config - -Version: v27.4.7 +Upstream: https://github.com/facebook/jest/tree/v27.4.7/packages/jest-config --- @@ -136,11 +132,3 @@ Version: v27.4.7 * `watchAll` * `watchman` * `watchPathIgnorePatterns` - -### :x: Excluded -``` -``` - -### :package: [Dependencies](https://github.com/facebook/jest/blob/v27.4.7/packages/jest-config/package.json) -| Package | Version | Status | Notes | -| ------- | ------- | ------ | ----- | diff --git a/src/jest-config/src/Defaults.lua b/src/jest-config/src/Defaults.lua index 8576ae27..6d9769b9 100644 --- a/src/jest-config/src/Defaults.lua +++ b/src/jest-config/src/Defaults.lua @@ -75,6 +75,8 @@ local defaultOptions: Config_DefaultOptions = { -- ROBLOX deviation START: not supported -- notifyMode = "failure-change", -- ROBLOX deviation END + -- ROBLOX deviation: inject alike types + oldFunctionSpying = true, passWithNoTests = false, -- ROBLOX deviation START: not supported -- prettierPath = "prettier", diff --git a/src/jest-config/src/init.lua b/src/jest-config/src/init.lua index 9d9ee7f1..c104b50d 100644 --- a/src/jest-config/src/init.lua +++ b/src/jest-config/src/init.lua @@ -1,3 +1,4 @@ +--!strict -- ROBLOX upstream: https://github.com/facebook/jest/blob/v28.0.0/packages/jest-config/src/index.ts --[[* * Copyright (c) Facebook, Inc. and its affiliates. All Rights Reserved. @@ -284,6 +285,8 @@ function groupOptions(options: Config_ProjectConfig & Config_GlobalConfig): { -- modulePaths = options.modulePaths, -- prettierPath = options.prettierPath, -- ROBLOX deviation END + -- ROBLOX deviation: inject alike types + oldFunctionSpying = options.oldFunctionSpying, resetMocks = options.resetMocks, resetModules = options.resetModules, -- ROBLOX deviation START: not supported @@ -461,4 +464,18 @@ local function readConfigs( end) end exports.readConfigs = readConfigs + +-- ROBLOX deviation START: type checked project and global config defaults +do + -- Some configuration such as `displayName` isn't included in this. If this + -- is something you depend on knowing, you should be using an actual + -- configuration file instead. + -- This is only provided here for the convenience of Jest unit tests, which + -- need to have a reasonable baseline which they may configure further. + local grouped = groupOptions(exports.defaults :: any) + exports.globalDefaults = grouped.globalConfig + exports.projectDefaults = grouped.projectConfig +end +-- ROBLOX deviation END + return exports diff --git a/src/jest-config/src/normalize.lua b/src/jest-config/src/normalize.lua index 6d367ef6..ff2b5dcf 100644 --- a/src/jest-config/src/normalize.lua +++ b/src/jest-config/src/normalize.lua @@ -1170,6 +1170,7 @@ local function normalize( or key == "onlyChanged" or key == "onlyFailures" or key == "outputFile" + or key == "oldFunctionSpying" or key == "passWithNoTests" or key == "replname" or key == "reporters" diff --git a/src/jest-console/README.md b/src/jest-console/README.md index b6471e54..bee2067a 100644 --- a/src/jest-console/README.md +++ b/src/jest-console/README.md @@ -1,19 +1,7 @@ # jest-console -Status: :heavy_check_mark: Ported - -Source: https://github.com/facebook/jest/tree/v27.4.7/packages/jest-console - -Version: v27.4.7 +Upstream: https://github.com/facebook/jest/tree/v27.4.7/packages/jest-console --- ### :pencil2: Notes - -### :x: Excluded -``` -``` - -### :package: [Dependencies](https://github.com/facebook/jest/blob/v27.4.7/packages/jest-console/package.json) -| Package | Version | Status | Notes | -| ------- | ------- | ------ | ----- | diff --git a/src/jest-core/README.md b/src/jest-core/README.md index 4bb7cd74..f0cd0bf3 100644 --- a/src/jest-core/README.md +++ b/src/jest-core/README.md @@ -1,19 +1,7 @@ # jest-core -Status: :hammer: In Progress - -Source: https://github.com/facebook/jest/tree/v27.4.7/packages/jest-core - -Version: v27.4.7 +Upstream: https://github.com/facebook/jest/tree/v27.4.7/packages/jest-core --- ### :pencil2: Notes - -### :x: Excluded -``` -``` - -### :package: [Dependencies](https://github.com/facebook/jest/blob/v27.4.7/packages/jest-core/package.json) -| Package | Version | Status | Notes | -| ------- | ------- | ------ | ----- | diff --git a/src/jest-core/src/runJest.lua b/src/jest-core/src/runJest.lua index 49c12e12..cd8aba5a 100644 --- a/src/jest-core/src/runJest.lua +++ b/src/jest-core/src/runJest.lua @@ -11,6 +11,7 @@ local Array = LuauPolyfill.Array local Object = LuauPolyfill.Object local Set = LuauPolyfill.Set local console = LuauPolyfill.console +local Boolean = LuauPolyfill.Boolean type Array = LuauPolyfill.Array type Object = LuauPolyfill.Object type Promise = LuauPolyfill.Promise @@ -92,6 +93,10 @@ local process = nodeUtils.process local exit = nodeUtils.exit type NodeJS_WriteStream = RobloxShared.NodeJS_WriteStream local JSON = nodeUtils.JSON +local ensureDirectoryExists = RobloxShared.ensureDirectoryExists +local getDataModelService = RobloxShared.getDataModelService +local FileSystemService = getDataModelService("FileSystemService") +local CoreScriptSyncService = getDataModelService("CoreScriptSyncService") -- ROBLOX deviation END local function getTestPaths( @@ -181,16 +186,13 @@ local function processResults(runResults: AggregatedResult, options: ProcessResu -- ROBLOX deviation END if isJSON then - -- ROBLOX deviation START: no output to file support - -- if Boolean.toJSBoolean(outputFile) then - -- local cwd = tryRealpath(process:cwd()) - -- local filePath = path:resolve(cwd, outputFile) - -- fs:writeFileSync(filePath, JSON.stringify(formatTestResults(runResults))) - -- outputStream:write(("Test results written to: %s\n"):format(tostring(path:relative(cwd, filePath)))) - -- else - process.stdout:write(JSON.stringify(formatTestResults(runResults))) - -- end - -- ROBLOX deviation END + if _outputFile and FileSystemService and Boolean.toJSBoolean(_outputFile) then + ensureDirectoryExists(_outputFile) + FileSystemService:WriteFile(_outputFile, JSON.stringify(formatTestResults(runResults))) + process.stdout:write(("Test results written to: %s\n"):format(tostring(_outputFile))) + else + process.stdout:write(JSON.stringify(formatTestResults(runResults))) + end end if onComplete ~= nil then @@ -289,6 +291,10 @@ local function runJest(ref: { if globalConfig.listTests then local testsPaths = Array.from(Set.new(Array.map(allTests, function(test) + -- ROBLOX deviation: resolve to a FS path if CoreScriptSyncService is available + if CoreScriptSyncService then + return CoreScriptSyncService:GetScriptFilePath(test.script) + end return test.path end))) --[[ eslint-disable no-console ]] diff --git a/src/jest-diff/README.md b/src/jest-diff/README.md index bceb844d..c595850e 100644 --- a/src/jest-diff/README.md +++ b/src/jest-diff/README.md @@ -1,16 +1,24 @@ # jest-diff -Status: :hammer: In Progress +Upstream: https://github.com/facebook/jest/tree/v27.4.7/packages/jest-diff -Source: https://github.com/facebook/jest/tree/v27.4.7/packages/jest-diff +Display differences clearly so people can review changes confidently. -Version: v27.4.7 +The `diff` named export serializes **values**, compares them line-by-line, and returns a string which includes comparison lines. + +Two named exports compare **strings** character-by-character: + +- `diffStringsUnified` returns a string. +- `diffStringsRaw` returns an array of `Diff` objects. + +Three named exports compare **arrays of strings** line-by-line: + +- `diffLinesUnified` and `diffLinesUnified2` return a string. +- `diffLinesRaw` returns an array of `Diff` objects. --- ### :pencil2: Notes -* :x: Color formatting isn't supported. -* :hammer: Currently doesn't support any features that require `prettyFormat` plugins (e.g. React elements). * `CleanupSemantic.lua` is adapted from the Lua version of [`diff-match-patch`](https://github.com/google/diff-match-patch/blob/master/lua/diff_match_patch.lua) to resemble the upstream [`cleanupSemantics.ts`](https://github.com/facebook/jest/blob/v27.4.7/packages/jest-diff/src/cleanupSemantic.ts) instead of being a direct port of it. * Tests for it are added, which are not included in the upstream `jest-diff * Changes to tests: @@ -18,16 +26,3 @@ Version: v27.4.7 * Color formatting specific tests are omitted. * `changeColor` is assigned to a function that imitates `chalk.inverse` so we can test `diffStringsUnified`. * `Array[]`, `Object{}` are changed to `Table{}`. - -### :x: Excluded -``` -src/types.ts -``` - -### :package: [Dependencies](https://github.com/facebook/jest/blob/v27.4.7/packages/jest-diff/package.json) -| Package | Version | Status | Notes | -| -------------- | ------- | ------------------------- | ------------------------------------------------ | -| chalk | 4.0.0 | :heavy_check_mark: Ported | [Lua-Chalk](https://github.com/Roblox/lua-chalk) | -| diff-sequences | 27.4.0 | :heavy_check_mark: Ported | | -| jest-get-type | 27.4.0 | :heavy_check_mark: Ported | | -| pretty-format | 27.4.6 | :heavy_check_mark: Ported | Mostly complete, need plugins | diff --git a/src/jest-diff/src/PrettyFormat.lua b/src/jest-diff/src/PrettyFormat.lua index f7d54f9f..34840c04 100644 --- a/src/jest-diff/src/PrettyFormat.lua +++ b/src/jest-diff/src/PrettyFormat.lua @@ -42,6 +42,7 @@ export type PrettyFormatOptions = { printBasicPrototype: boolean?, printInstanceDefaults: boolean?, printFunctionName: boolean?, + redactStackTracesInStrings: boolean?, theme: ThemeReceived?, } diff --git a/src/jest-each/README.md b/src/jest-each/README.md index b6e058df..c8f7a61e 100644 --- a/src/jest-each/README.md +++ b/src/jest-each/README.md @@ -1,10 +1,8 @@ # jest-each -Status: :heavy_check_mark: Ported +Upstream: https://github.com/facebook/jest/tree/v27.4.7/packages/jest-each -Source: https://github.com/facebook/jest/tree/v27.4.7/packages/jest-each - -Version: v27.4.7 +A parameterized testing library for Jest. You can find its documentation in the [Jest documentation](https://roblox.github.io/jest-roblox-internal). --- @@ -34,14 +32,3 @@ Version: v27.4.7 ) ``` * TestEZ methods are supported (`*FOCUS`, `*SKIP`), however, these may be dropped at some point in favor of jest's callable objects(`it`, `it.only`, `it.skip`), which are supported too - -### :x: Excluded - -``` - -``` - -### :package: [Dependencies](https://github.com/facebook/jest/blob/v27.4.7/packages/jest-each/package.json) - -| Package | Version | Status | Notes | -| ------- | ------- | ------ | ----- | diff --git a/src/jest-environment-roblox/README.md b/src/jest-environment-roblox/README.md index 3b4f7fb0..a4a68ba9 100644 --- a/src/jest-environment-roblox/README.md +++ b/src/jest-environment-roblox/README.md @@ -1,6 +1,6 @@ # jest-environment-roblox -Status: :hammer: In Progress +*No upstream. Roblox only.* --- diff --git a/src/jest-environment/README.md b/src/jest-environment/README.md index 172b2643..2c5514f6 100644 --- a/src/jest-environment/README.md +++ b/src/jest-environment/README.md @@ -1,22 +1,7 @@ # jest-environment -Status: :hammer: In Progress - -Source: https://github.com/facebook/jest/tree/v27.4.7/packages/jest-environment - -Version: v27.4.7 +Upstream: https://github.com/facebook/jest/tree/v27.4.7/packages/jest-environment --- ### :pencil2: Notes - -### :x: Excluded - -``` - -``` - -### :package: [Dependencies](https://github.com/facebook/jest/blob/v27.4.7/packages/jest-environment/package.json) - -| Package | Version | Status | Notes | -| ------- | ------- | ------ | ----- | diff --git a/src/jest-environment/package.json b/src/jest-environment/package.json index c3a1bb26..0eee599e 100644 --- a/src/jest-environment/package.json +++ b/src/jest-environment/package.json @@ -14,6 +14,7 @@ "dependencies": { "@jsdotlua/jest-fake-timers": "workspace:^", "@jsdotlua/jest-mock": "workspace:^", + "@jsdotlua/jest-mock-genv": "workspace:^", "@jsdotlua/jest-types": "workspace:^", "@jsdotlua/luau-polyfill": "^1.2.6" }, diff --git a/src/jest-environment/src/init.lua b/src/jest-environment/src/init.lua index 4a68c540..1a099af9 100644 --- a/src/jest-environment/src/init.lua +++ b/src/jest-environment/src/init.lua @@ -37,13 +37,17 @@ type Global_Global = typesModule.Global_Global -- ROBLOX deviation END local jestMockModule = require("@pkg/@jsdotlua/jest-mock") -local JestMockFn = jestMockModule.fn -local JestMockMocked = jestMockModule.mocked --- ROBLOX TODO: spyOn is not implemented --- local JestMockSpyOn = jestMockModule.spyOn +-- ROBLOX deviation START: can't export globals from jest mock +type JestFuncFn = jestMockModule.JestFuncFn +type JestFuncMocked = jestMockModule.JestFuncMocked +type JestFuncSpyOn = jestMockModule.JestFuncSpyOn +-- ROBLOX deviation END type ModuleMocker = jestMockModule.ModuleMocker +-- ROBLOX deviation: mocking globals +local jestMockGenvModule = require("@pkg/@jsdotlua/jest-mock-genv") + export type EnvironmentContext = { console: Console, -- docblockPragmas: Record>, @@ -112,7 +116,8 @@ export type Jest = { * of the specified module, including all of the specified module's * dependencies. ]] - deepUnmock: (moduleName: string) -> Jest, + -- ROBLOX deviation: using ModuleScript instead of string + deepUnmock: (moduleName: ModuleScript) -> Jest, --[[* * Disables automatic mocking in the module loader. * @@ -125,13 +130,15 @@ export type Jest = { * the top of the code block. Use this method if you want to explicitly avoid * this behavior. ]] - doMock: (moduleName: string, moduleFactory: (() -> any)?) -> Jest, + -- ROBLOX deviation: using ModuleScript instead of string + doMock: (moduleName: ModuleScript, moduleFactory: (() -> any)?) -> Jest, --[[* * Indicates that the module system should never return a mocked version * of the specified module from require() (e.g. that it should always return * the real module). ]] - dontMock: (moduleName: string) -> Jest, + -- ROBLOX deviation: using ModuleScript instead of string + dontMock: (moduleName: ModuleScript) -> Jest, --[[* * Enables automatic mocking in the module loader. ]] @@ -139,7 +146,15 @@ export type Jest = { --[[* * Creates a mock function. Optionally takes a mock implementation. ]] - fn: typeof(JestMockFn), + -- ROBLOX deviation: can't export globals from jest mock + fn: JestFuncFn, + -- ROBLOX deviation START: mocking globals + --[[* + * Represents the global environment and its libraries, for use with the + * `spyOn()` function. This can be used to spy on Lua globals. + ]] + globalEnv: jestMockGenvModule.GlobalEnv, + -- ROBLOX deviation END --[[* * Given the name of a module, use the automatic mocking system to generate a * mocked version of the module for you. @@ -149,7 +164,8 @@ export type Jest = { * * @deprecated Use `jest.createMockFromModule()` instead ]] - genMockFromModule: (moduleName: string) -> any, + -- ROBLOX deviation: using ModuleScript instead of string + genMockFromModule: (moduleName: ModuleScript) -> any, --[[* * Given the name of a module, use the automatic mocking system to generate a * mocked version of the module for you. @@ -157,7 +173,8 @@ export type Jest = { * This is useful when you want to create a manual mock that extends the * automatic mock's behavior. ]] - createMockFromModule: (moduleName: string) -> any, + -- ROBLOX deviation: using ModuleScript instead of string + createMockFromModule: (moduleName: ModuleScript) -> any, --[[* * Determines if the given function is a mocked function. ]] @@ -165,13 +182,15 @@ export type Jest = { --[[* * Mocks a module with an auto-mocked version when it is being required. ]] - mock: (moduleName: string, moduleFactory: (() -> any)?, options: { virtual: boolean? }?) -> Jest, + -- ROBLOX deviation: using ModuleScript instead of string + mock: (moduleName: ModuleScript, moduleFactory: (() -> any)?, options: { virtual: boolean? }?) -> Jest, --[[* * Mocks a module with the provided module factory when it is being imported. ]] -- ROBLOX TODO: add default generic. unstable_mockModule: ( - moduleName: string, + -- ROBLOX deviation: using ModuleScript instead of string + moduleName: ModuleScript, moduleFactory: () -> Promise | T, options: { virtual: boolean? }? ) -> Jest, @@ -194,12 +213,14 @@ export type Jest = { getRandom(); // Always returns 10 ``` ]] - requireActual: (moduleName: string) -> any, + -- ROBLOX deviation: using ModuleScript instead of string + requireActual: (moduleName: ModuleScript) -> any, --[[* * Returns a mock module instead of the actual module, bypassing all checks * on whether the module should be required normally or not. ]] - requireMock: (moduleName: string) -> any, + -- ROBLOX deviation: using ModuleScript instead of string + requireMock: (moduleName: ModuleScript) -> any, --[[* * Resets the state of all mocks. * Equivalent to calling .mockReset() on every mocked function. @@ -218,7 +239,8 @@ export type Jest = { * jest.spyOn; other mocks will require you to manually restore them. ]] restoreAllMocks: () -> Jest, - mocked: typeof(JestMockMocked), + -- ROBLOX deviation: can't export globals from jest mock + mocked: JestFuncMocked, --[[* * Runs failed tests n-times until they pass or until the max number of * retries is exhausted. This only works with `jest-circus`! @@ -265,7 +287,8 @@ export type Jest = { * API's second argument is a module factory instead of the expected * exported module object. ]] - setMock: (moduleName: string, moduleExports: any) -> Jest, + -- ROBLOX deviation: using ModuleScript instead of string + setMock: (moduleName: ModuleScript, moduleExports: any) -> Jest, --[[* * Set the default timeout interval for tests and before/after hooks in * milliseconds. @@ -281,14 +304,15 @@ export type Jest = { * Note: By default, jest.spyOn also calls the spied method. This is * different behavior from most other test libraries. ]] - -- ROBLOX TODO: spyOn is not implemented - -- spyOn: typeof(JestMockSpyOn), + -- ROBLOX deviation: can't export globals from jest mock + spyOn: JestFuncSpyOn, --[[* * Indicates that the module system should never return a mocked version of * the specified module from require() (e.g. that it should always return the * real module). ]] - unmock: (moduleName: string) -> Jest, + -- ROBLOX deviation: using ModuleScript instead of string + unmock: (moduleName: ModuleScript) -> Jest, --[[* * Instructs Jest to use fake versions of the standard timer functions. ]] diff --git a/src/jest-fake-timers/README.md b/src/jest-fake-timers/README.md index c1fdb69c..4301ca17 100644 --- a/src/jest-fake-timers/README.md +++ b/src/jest-fake-timers/README.md @@ -1,10 +1,21 @@ # jest-fake-timers -Status: :hammer: In Progress - -Source: https://github.com/facebook/jest/tree/v27.4.7/packages/jest-fake-timers - -Version: v27.4.7 +Upstream: https://github.com/facebook/jest/tree/v27.4.7/packages/jest-fake-timers + +This package contains the fake timers implementation for Jest. It can be activated by calling `jest.useFakeTimers()`. You can find its documentation in the [Jest documentation](https://roblox.github.io/jest-roblox-internal). + +The following timers are mocked: +* `delay` +* `tick` +* `time` +* `os` + * `os.time` + * `os.clock` +* `task.delay` + * `task.delay` + * `task.cancel` + * `task.wait` +* `DateTime` --- @@ -13,20 +24,3 @@ Version: v27.4.7 Similar to how Jest fake timers work by mocking the native timer functions (i.e. `setTimeout`, `setInterval`, `clearTimeout`, `clearInterval`) and `Date`, Jest Lua fake timers work by mocking the native timer functions in Roblox (i.e. `delay`, `tick`), the Roblox `DateTime` and the Lua native timer methods `os.time` and `os.clock`. Additionally, Jest Lua fake timers support a configurable engine frame time. By default, the engine frame time is 0 (i.e. continuous time), but if set, Jest Lua fake timers will be processed by multiples of frame time. If engine frame time is set, then timers will be processed in the first frame after they are triggered. - -### :x: Excluded -``` -src/legacyFakeTimers.ts -__tests__/legacyFakeTimers.test.ts -__tests__/__snapshots__/legacyFakeTimers.test.ts.snap -``` - -### :package: [Dependencies](https://github.com/facebook/jest/blob/v27.4.7/packages/jest-fake-timers/package.json) -| Package | Version | Status | Notes | -| -------------------- | ------- | ------------------------- | ----- | -| @jest/types | 27.4.2 | :heavy_check_mark: Ported | | -| @sinonjs/fake-timers | 8.0.1 | :x: Not needed | | -| @types/node | * | :x: Not needed | | -| jest-message-util | 27.4.6 | :x: Not needed | | -| jest-mock | 27.4.6 | :x: Not needed | | -| jest-util | 27.4.2 | :hammer: In Progress | | diff --git a/src/jest-fake-timers/src/__tests__/roblox.spec.lua b/src/jest-fake-timers/src/__tests__/roblox.spec.lua index 8b172327..fd103cab 100644 --- a/src/jest-fake-timers/src/__tests__/roblox.spec.lua +++ b/src/jest-fake-timers/src/__tests__/roblox.spec.lua @@ -867,18 +867,23 @@ describe("task", function() it("resets native timer APIs", function() local nativeTaskDelay = timers.taskOverride.delay.getMockImplementation() local nativeTaskCancel = timers.taskOverride.cancel.getMockImplementation() + local nativeTaskWait = timers.taskOverride.wait.getMockImplementation() timers:useFakeTimers() local fakeTaskDelay = timers.taskOverride.delay.getMockImplementation() local fakeTaskCancel = timers.taskOverride.cancel.getMockImplementation() + local fakeTaskWait = timers.taskOverride.wait.getMockImplementation() expect(fakeTaskDelay).never.toBe(nativeTaskDelay) expect(fakeTaskCancel).never.toBe(nativeTaskCancel) + expect(fakeTaskWait).never.toBe(nativeTaskWait) timers:useRealTimers() local realTaskDelay = timers.taskOverride.delay.getMockImplementation() local realTaskCancel = timers.taskOverride.cancel.getMockImplementation() + local realTaskWait = timers.taskOverride.wait.getMockImplementation() expect(realTaskDelay).toBe(nativeTaskDelay) expect(realTaskCancel).toBe(nativeTaskCancel) + expect(realTaskWait).toBe(nativeTaskWait) end) end) end) diff --git a/src/jest-fake-timers/src/__tests__/timers.roblox.spec.lua b/src/jest-fake-timers/src/__tests__/timers.roblox.spec.lua index d614b21d..0c3544f1 100644 --- a/src/jest-fake-timers/src/__tests__/timers.roblox.spec.lua +++ b/src/jest-fake-timers/src/__tests__/timers.roblox.spec.lua @@ -12,15 +12,16 @@ local test = JestGlobals.test local jest = JestGlobals.jest local FRAME_TIME = 15 -describe("timers", function() +describe("setTimeout", function() beforeEach(function() jest.useFakeTimers() end) + afterEach(function() jest.useRealTimers() end) - test("setTimeout - should not trigger", function() + test("should not trigger", function() local triggered = false setTimeout(function() triggered = true @@ -29,7 +30,7 @@ describe("timers", function() expect(triggered).toBe(false) end) - test("setTimeout - should trigger", function() + test("should trigger", function() local triggered = false setTimeout(function() triggered = true @@ -38,7 +39,28 @@ describe("timers", function() expect(triggered).toBe(true) end) - test("setInterval - should not trigger", function() + test("should trigger with a configurable frame time", function() + jest.useFakeTimers() + jest.setEngineFrameTime(FRAME_TIME) + local triggered = false + setTimeout(function() + triggered = true + end, 10) + jest.advanceTimersByTime(0) + expect(triggered).toBe(true) + end) +end) + +describe("setInterval", function() + beforeEach(function() + jest.useFakeTimers() + end) + + afterEach(function() + jest.useRealTimers() + end) + + test("should not trigger", function() local triggered = 0 setInterval(function() triggered += 1 @@ -47,7 +69,7 @@ describe("timers", function() expect(triggered).toBe(0) end) - test("setInterval - should trigger once", function() + test("should trigger once", function() local triggered = 0 setInterval(function() triggered += 1 @@ -56,7 +78,7 @@ describe("timers", function() expect(triggered).toBe(1) end) - test("setInterval - should trigger multiple times", function() + test("should trigger multiple times", function() local triggered = 0 setInterval(function() triggered += 1 @@ -82,8 +104,18 @@ describe("timers", function() jest.advanceTimersByTime(1) expect(triggered).toBe(3) end) +end) - test("task.delay - should trigger", function() +describe("task.delay", function() + beforeEach(function() + jest.useFakeTimers() + end) + + afterEach(function() + jest.useRealTimers() + end) + + test("should trigger", function() local triggered = false task.delay(2, function() triggered = true @@ -92,7 +124,7 @@ describe("timers", function() expect(triggered).toBe(true) end) - test("task.delay - should not trigger", function() + test("should not trigger", function() local triggered = false task.delay(2, function() triggered = true @@ -109,8 +141,18 @@ describe("timers", function() jest.advanceTimersByTime(10000) expect(triggered).toBe(true) end, 1000) +end) - test("task.cancel - timeout should be canceled and not trigger", function() +describe("task.cancel", function() + beforeEach(function() + jest.useFakeTimers() + end) + + afterEach(function() + jest.useRealTimers() + end) + + test("timeout should be canceled and not trigger", function() local triggered = false local timeout = task.delay(2, function() triggered = true @@ -120,7 +162,7 @@ describe("timers", function() expect(triggered).toBe(false) end) - test("task.cancel - one timeout should be canceled and not trigger", function() + test("one timeout should be canceled and not trigger", function() local triggered1 = false local triggered2 = false local timeout1 = task.delay(2, function() @@ -136,7 +178,7 @@ describe("timers", function() expect(triggered2).toBe(true) end) - test("task.cancel - cancel after delayed task runs", function() + test("cancel after delayed task runs", function() local triggered = false local timeout = task.delay(2, function() triggered = true @@ -147,15 +189,53 @@ describe("timers", function() end) end) -describe("timers with configurable frame time", function() - test("setTimeout - should trigger", function() +describe("task.wait", function() + beforeEach(function() jest.useFakeTimers() - jest.setEngineFrameTime(FRAME_TIME) - local triggered = false - setTimeout(function() - triggered = true - end, 10) - jest.advanceTimersByTime(0) - expect(triggered).toBe(true) + end) + + afterEach(function() + jest.useRealTimers() + end) + + test("should wait for the specified time", function() + local elapsed = 0 + coroutine.wrap(function() + elapsed = task.wait(2) + end)() + jest.advanceTimersByTime(2000) + expect(elapsed).toBe(2) + end) + + test("should not proceed before the specified time", function() + local elapsed = 0 + coroutine.wrap(function() + elapsed = task.wait(2) + end)() + jest.advanceTimersByTime(1999) + expect(elapsed).toBe(0) + end) + + test("should default to frame time if no time is specified", function() + local elapsed = 0 + coroutine.wrap(function() + elapsed = task.wait() + end)() + jest.advanceTimersByTime(1000 / 60) + expect(elapsed).toBeCloseTo(1 / 60, 0.001) + end) + + test("multiple waits should accumulate correctly", function() + local elapsed1, elapsed2 = 0, 0 + coroutine.wrap(function() + elapsed1 = task.wait(1) + elapsed2 = task.wait(2) + end)() + jest.advanceTimersByTime(1000) + expect(elapsed1).toBe(1) + expect(elapsed2).toBe(0) + jest.advanceTimersByTime(2000) + expect(elapsed1).toBe(1) + expect(elapsed2).toBe(2) end) end) diff --git a/src/jest-fake-timers/src/init.lua b/src/jest-fake-timers/src/init.lua index 49a1a618..6a175bf1 100644 --- a/src/jest-fake-timers/src/init.lua +++ b/src/jest-fake-timers/src/init.lua @@ -84,6 +84,7 @@ function FakeTimers.new(): FakeTimers local taskOverride = { delay = mock:fn(realTask.delay), cancel = mock:fn(realTask.cancel), + wait = mock:fn(realTask.wait), } setmetatable(taskOverride, { __index = realTask }) @@ -220,10 +221,15 @@ function FakeTimers:useRealTimers(): () self.osOverride.clock.mockImplementation(realOs.clock) self.taskOverride.delay.mockImplementation(realTask.delay) self.taskOverride.cancel.mockImplementation(realTask.cancel) + self.taskOverride.wait.mockImplementation(realTask.wait) self._fakingTime = false end end +local function fakeClock(self): number + return self._mockTimeMs / 1000 +end + local function fakeDelay(self, delayTime, callback, ...): Timeout -- Small hack to make sure 0 second recursive timers don't trigger twice in a single frame local delayTimeMs = (self._engineFrameTime / 1000) + delayTime * 1000 @@ -257,20 +263,33 @@ local function fakeCancel(self, timeout) end end +local function fakeWait(self, timeToWait: number?) + local running = coroutine.running() + local clock = fakeClock(self) + fakeDelay(self, timeToWait or 0, function() + task.spawn(running, fakeClock(self) - clock) + end) + return coroutine.yield() +end + function FakeTimers:useFakeTimers(): () if not self._fakingTime then self.delayOverride.mockImplementation(function(delayTime, callback) return fakeDelay(self, delayTime, callback) end) + self.tickOverride.mockImplementation(function() return self._mockSystemTime end) + self.timeOverride.mockImplementation(function() - return self._mockTimeMs / 1000 + return fakeClock(self) end) + self.dateTimeOverride.now.mockImplementation(function() return realDateTime.fromUnixTimestamp(self._mockSystemTime) end) + self.osOverride.time.mockImplementation(function(time_) if typeof(time_) == "table" then local unixTime = realDateTime.fromUniversalTime( @@ -285,9 +304,11 @@ function FakeTimers:useFakeTimers(): () end return self._mockSystemTime end) + self.osOverride.clock.mockImplementation(function() - return self._mockTimeMs / 1000 + return fakeClock(self) end) + self.taskOverride.delay.mockImplementation(function(delayTime, callback, ...) return fakeDelay(self, delayTime, callback, ...) end) @@ -296,6 +317,10 @@ function FakeTimers:useFakeTimers(): () fakeCancel(self, timeout) end) + self.taskOverride.wait.mockImplementation(function(timeToWait) + return fakeWait(self, timeToWait) + end) + self._fakingTime = true self:reset() end diff --git a/src/jest-get-type/README.md b/src/jest-get-type/README.md index 48570e4d..249a30fa 100644 --- a/src/jest-get-type/README.md +++ b/src/jest-get-type/README.md @@ -1,10 +1,14 @@ # jest-get-type -Status: :heavy_check_mark: Ported +Upstream: https://github.com/facebook/jest/tree/v27.4.7/packages/jest-get-type -Source: https://github.com/facebook/jest/tree/v27.4.7/packages/jest-get-type +A utility function to get the type of a value, including Luau and Roblox types. -Version: v27.4.7 +Types supported: + +* Lua Primitives - `nil`, `table`, `number`, `string`, `function`, `boolean`, `userdata`, `thread` +* [Luau Polyfill](https://github.com/Roblox/luau-polyfill) types - `symbol`, [`regexp`](https://github.com/Roblox/luau-regexp), `error`, `set` +* Roblox datatypes - `DateTime`, and other [`builtin`](https://developer.roblox.com/en-us/api-reference/data-types) types --- @@ -14,17 +18,3 @@ Version: v27.4.7 * Lua lacks the following primitives: `bigint`, `symbol`. * Lua lacks the following built-in types: `RegExp`, `Map`, `Set`, `Date`. * `JestGetType` deviates and exposes an `isRobloxBuiltin` method to check whether a value is a Roblox builtin type - -Types supported: - -* Lua Primitives - `nil`, `table`, `number`, `string`, `function`, `boolean`, `userdata`, `thread` -* [Luau Polyfill](https://github.com/Roblox/luau-polyfill) types - `symbol`, [`regexp`](https://github.com/Roblox/luau-regexp), `error`, `set` -* Roblox datatypes - `DateTime`, and other [`builtin`](https://developer.roblox.com/en-us/api-reference/data-types) types - -### :x: Excluded -``` -``` - -### :package: [Dependencies](https://github.com/facebook/jest/blob/v27.4.7/packages/jest-get-type/package.json) -| Package | Version | Status | Notes | -| ------- | ------- | ------ | ----- | diff --git a/src/jest-globals/README.md b/src/jest-globals/README.md index 4fd37580..422e0380 100644 --- a/src/jest-globals/README.md +++ b/src/jest-globals/README.md @@ -1,19 +1,9 @@ # jest-globals -Status: :hammer: In Progress +Upstream: https://github.com/jestjs/jest/tree/v27.4.7/packages/jest-globals -Source: - -Version: +This package exports all the "global" methods used by Jest. You can find its documentation in the [Jest documentation](https://roblox.github.io/jest-roblox-internal). --- ### :pencil2: Notes - -### :x: Excluded -``` -``` - -### :package: [Dependencies]() -| Package | Version | Status | Notes | -| ------- | ------- | ------ | ----- | diff --git a/src/jest-globals/src/__tests__/index.lua b/src/jest-globals/src/__tests__/index.lua index e20a65c7..76caeb25 100644 --- a/src/jest-globals/src/__tests__/index.lua +++ b/src/jest-globals/src/__tests__/index.lua @@ -26,7 +26,8 @@ return (function() require("../index") end).toThrowError( -- ROBLOX deviation START: aligned message to make sense for jest-roblox - "Do not import `JestGlobals` outside of the Jest test environment" + "Do not import `JestGlobals` outside of the Jest 3 test environment.\n" + .. "Tip: Jest 2 uses a different pattern - check your Jest version." -- ROBLOX deviation END ) end) diff --git a/src/jest-globals/src/index.lua b/src/jest-globals/src/index.lua index 8593ad2f..efebbe99 100644 --- a/src/jest-globals/src/index.lua +++ b/src/jest-globals/src/index.lua @@ -35,7 +35,8 @@ type JestGlobals = error(Error.new( -- ROBLOX deviation START: aligned message to make sense for jest-roblox - "Do not import `JestGlobals` outside of the Jest test environment" + "Do not import `JestGlobals` outside of the Jest 3 test environment.\n" + .. "Tip: Jest 2 uses a different pattern - check your Jest version." -- ROBLOX deviation END )) diff --git a/src/jest-jasmine2/README.md b/src/jest-jasmine2/README.md index 6b58ae69..85092a9a 100644 --- a/src/jest-jasmine2/README.md +++ b/src/jest-jasmine2/README.md @@ -1,10 +1,6 @@ # jest-jasmine2 -Status: :hammer: In Progress - -Source: https://github.com/facebook/jest/tree/v27.4.7/packages/jest-jasmine2/src/jasmine - -Version: v27.4.7 +Upstream: https://github.com/facebook/jest/tree/v27.4.7/packages/jest-jasmine2 --- @@ -13,12 +9,3 @@ Version: v27.4.7 * The tests for CallTracker and SpyStrategy are copied off of the upstream files but the `createSpy.ts` file doesn't actually have a direct upstream equivalent in Jasmine so we copy some tests from `SpySpec` instead, leaving out the majority of tests that aren't yet relevant * We expose `andAlso` in addition to the typical `and` for createSpy since `and` is a built in keyword for Lua so we can't cleanly chain fields (i.e. we can't get `var.and` to work so we allow for `var.andAlso`) * We use the [Roblox Lua Promise](https://github.com/evaera/roblox-lua-promise) library in this module in a number of places to more closely mirror the asynchronous tests in Jasmine - - -### :x: Excluded -``` -``` - -### :package: [Dependencies](https://github.com/facebook/jest/blob/v27.4.7/packages/jest-jasmine2/package.json) -| Package | Version | Status | Notes | -| ------- | ------- | ------ | ----- | diff --git a/src/jest-matcher-utils/README.md b/src/jest-matcher-utils/README.md index 88c22e4c..5e63a809 100644 --- a/src/jest-matcher-utils/README.md +++ b/src/jest-matcher-utils/README.md @@ -1,10 +1,8 @@ # jest-matcher-utils -Status: :heavy_check_mark: Ported +Upstream: https://github.com/facebook/jest/tree/v27.4.7/packages/jest-matcher-utils -Source: https://github.com/facebook/jest/tree/v27.4.7/packages/jest-matcher-utils - -Version: v27.4.7 +This package's exports are mainly used by `expect`'s `utils`. --- @@ -14,15 +12,3 @@ Version: v27.4.7 * Changed type annotations in upstream that were `unknown` to `any` because Luau doesn't have support for an `unknown` type * In many of the tests, there is differentiation between types such as `Array` and `Map`, however in Lua all of these are treated identically as `table`. The tests were translated to use the `table` type and some tests were left out if they became identical to other tests or were highly redundant. * Luau does not yet have functionality to use generics in function signatures and functions so those type annotations are left out - -### :x: Excluded -``` -``` - -### :package: [Dependencies](https://github.com/facebook/jest/blob/v27.4.7/packages/jest-matcher-utils/package.json) -| Package | Version | Status | Notes | -| ------------- | ------- | ------------------------- | ------------------------------------------------ | -| chalk | 4.0.0 | :heavy_check_mark: Ported | [Lua-Chalk](https://github.com/Roblox/lua-chalk) | -| jest-diff | 27.4.6 | :heavy_check_mark: Ported | | -| jest-get-type | 27.4.0 | :heavy_check_mark: Ported | | -| pretty-format | 27.4.6 | :heavy_check_mark: Ported | Mostly complete, need plugins | diff --git a/src/jest-matcher-utils/src/__tests__/index.spec.lua b/src/jest-matcher-utils/src/__tests__/index.spec.lua index 4a3bb201..4f5d17f3 100644 --- a/src/jest-matcher-utils/src/__tests__/index.spec.lua +++ b/src/jest-matcher-utils/src/__tests__/index.spec.lua @@ -439,7 +439,8 @@ end) describe("printDiffOrStringify", function() test("expected asymmetric matchers should be diffable", function() - jest.dontMock("jest-diff") + -- ROBLOX deviation: pass in ModuleScript instead of string + jest.dontMock(script.Parent.Parent.Parent.JestDiff) jest.resetModules() -- ROBLOX deviation START: fix incorrect 'require' call diff --git a/src/jest-message-util/README.md b/src/jest-message-util/README.md index 0bd68f2e..90449453 100644 --- a/src/jest-message-util/README.md +++ b/src/jest-message-util/README.md @@ -1,28 +1,7 @@ # jest-message-util -Status: :hammer: In Progress - -Source: https://github.com/facebook/jest/tree/v27.4.7/packages/jest-message-util - -Version: v27.4.7 +Upstream: https://github.com/facebook/jest/tree/v27.4.7/packages/jest-message-util --- ### :pencil2: Notes - -### :x: Excluded -``` -``` - -### :package: [Dependencies](https://github.com/facebook/jest/blob/v27.4.7/packages/jest-message-util/package.json) -| Package | Version | Status | Notes | -| ------------------ | ------- | ------------------------- | ------------------------------------------------ | -| @babel/code-frame | 7.0.0 | :x: Will not port | Babel is not needed | -| @jest/types | 27.4.2 | :heavy_check_mark: Ported | External typedefs not a priority | -| @types/stack-utils | 2.0.0 | :x: Will not port | External typedefs not a priority | -| chalk | 4.0.0 | :heavy_check_mark: Ported | [Lua-Chalk](https://github.com/Roblox/lua-chalk) | -| graceful-fs | 4.2.4 | :x: Will not port | No need to interact with the filesystem | -| micromatch | 4.0.4 | :x: Will not port | Deals with file paths | -| pretty-format | 27.4.6 | :heavy_check_mark: Ported | | -| slash | 3.0.0 | :x: Will not port | Deals with file paths | -| stack-utils | 2.0.3 | | | diff --git a/src/jest-message-util/src/init.lua b/src/jest-message-util/src/init.lua index c32a81d0..c1f7b5b0 100644 --- a/src/jest-message-util/src/init.lua +++ b/src/jest-message-util/src/init.lua @@ -27,7 +27,9 @@ local prettyFormat = require("@pkg/@jsdotlua/pretty-format").format type Path = Config_Path -- ROBLOX deviation START: additional dependencies -local normalizePromiseError = require("@pkg/@jsdotlua/jest-roblox-shared").normalizePromiseError +local RobloxShared = require("@pkg/@jsdotlua/jest-roblox-shared") +local normalizePromiseError = RobloxShared.normalizePromiseError +local cleanLoadStringStack = RobloxShared.cleanLoadStringStack -- ROBLOX deviation END -- ROBLOX deviation: forward declarations @@ -272,8 +274,8 @@ end -- ROBLOX deviation: config does not have StackTraceConfig type annotation local function formatPaths(config, relativeTestPath, line: string): string - -- ROBLOX deviation: we don't do any formatting of paths in Lua to align with upstream - return line + -- ROBLOX deviation: if loadstring is used, format the loadstring stacktrace to look like a path + return cleanLoadStringStack(line) end function getStackTraceLines(stack: string, options: StackTraceOptions): { string } diff --git a/src/jest-mock-genv/.npmignore b/src/jest-mock-genv/.npmignore new file mode 100644 index 00000000..afe7a485 --- /dev/null +++ b/src/jest-mock-genv/.npmignore @@ -0,0 +1,30 @@ +/.* +/scripts +/docs +/site + +/build +/roblox +/temp + +/*.json +/*.json5 +/*.yml +/*.toml +/*.md +/*.txt +/*.tgz + +**/*.d.lua +**/*.spec.lua +**/*.test.lua +**/tests +**/__tests__ +**/jest.config.lua + +**/*.rbxl +**/*.rbxlx +**/*.rbxl.lock +**/*.rbxlx.lock +**/*.rbxm +**/*.rbxmx diff --git a/src/jest-mock-genv/README.md b/src/jest-mock-genv/README.md new file mode 100644 index 00000000..6a427942 --- /dev/null +++ b/src/jest-mock-genv/README.md @@ -0,0 +1,37 @@ +# jest-mock-genv + +*No upstream. Roblox only.* + +This module houses the `GlobalMocker` class, the type definitions used for global +mocking utilities, and the `MOCKABLE_GLOBALS` constant which determines the global +environment members that are allowed to be mocked. + +## :pencil2: Notes + +- **Changing `MOCKABLE_GLOBALS` should be done with care.** + - By whitelisting a new global to be mocked, you may subtly affect any code + which uses that global, or allow users to do the same. + - Jest only generates mock functions for the globals that are + whitelisted, and doesn't generate anything for globals that are not + whitelisted. This can subtly change how a global appears to user code. + - Possible breakage will need to be investigated. + - Adding a new global to this list is not breaking, but removing a global + from this list *is* breaking. + - It is better to be selective than to be generous, because if the + whitelisting causes breakage, it's might be hard to undo. + - We also don't want to encourage bad practice, and mocking certain + globals could lead to unintended use cases which aren't idiomatic or + cause problems for ourselves later. + - Certain globals are not safe to mock right now, including task scheduling + functions and `require()`, because they already have customised + implementations in Jest that would be bypassed. + - This can probably be fixed down the line if there's a pressing need to + do it, but it would introduce more complexity. + - **Above all else, come talk to us first - we will help you 🙂** +- The `GlobalMocker` class does not implement any mocking capabilities itself; + instead, mock functions are stored in `GlobalMocker` by a `ModuleMocker`. +- Globals should *always* be mocked whenever a test is running, because the + test's sandbox environment redirects to these mock functions at all times - + even if the user has not used `spyOn`. + - This ensures that all modules can see mocked implementations, even if they + are required later than the call to `spyOn` which mocks the global. diff --git a/src/jest-mock-genv/package.json b/src/jest-mock-genv/package.json new file mode 100644 index 00000000..aa3ce84e --- /dev/null +++ b/src/jest-mock-genv/package.json @@ -0,0 +1,21 @@ +{ + "name": "@jsdotlua/jest-mock-genv", + "version": "3.6.1-rc.2", + "repository": { + "type": "git", + "url": "https://github.com/jsdotlua/jest-lua.git", + "directory": "src/jest-mock-genv" + }, + "license": "MIT", + "main": "src/init.lua", + "scripts": { + "prepare": "npmluau" + }, + "dependencies": { + "@jsdotlua/luau-polyfill": "^1.2.6" + }, + "devDependencies": { + "@jsdotlua/jest-globals": "workspace:^", + "npmluau": "^0.1.1" + } +} diff --git a/src/jest-mock-genv/src/__tests__/index.spec.lua b/src/jest-mock-genv/src/__tests__/index.spec.lua new file mode 100644 index 00000000..bd08b115 --- /dev/null +++ b/src/jest-mock-genv/src/__tests__/index.spec.lua @@ -0,0 +1,104 @@ +--[[ + * Copyright (c) Roblox Corporation. All rights reserved. + * Licensed under the MIT License (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://opensource.org/licenses/MIT + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. +]] +--!strict +-- ROBLOX NOTE: no upstream + +local LuauPolyfill = require("@pkg/@jsdotlua/luau-polyfill") +type Object = LuauPolyfill.Object + +local exports = require("..") +local JestGlobals = require("@pkg/@jsdotlua/jest-globals") +local jest = JestGlobals.jest +local expect = JestGlobals.expect +local it = JestGlobals.it +local describe = JestGlobals.describe +local beforeEach = JestGlobals.beforeEach + +local globalMocker +beforeEach(function() + globalMocker = exports.GlobalMocker.new() +end) + +it("MOCKABLE_GLOBALS has correct structure", function() + expect(exports.MOCKABLE_GLOBALS).toEqual(expect.any("table")) + local function checkStructureOf(partOfTable: Object) + for name, value in partOfTable do + expect(name).toEqual(expect.any("string")) + if typeof(value) == "table" then + -- Empty table here allow users to index this library in + -- `globalEnv`, but don't let them do anything else. To ensure + -- errors are raised from use of *libraries* with no mocks, + -- rather than attempts to mock specific *functions*, disallow + -- empty tables from being present in this structure. + expect((next(value))).never.toBeNil() + checkStructureOf(value) + else + expect(value).toEqual(expect.any("function")) + end + end + end + checkStructureOf(exports.MOCKABLE_GLOBALS) +end) + +it("globalEnv is provided in the environment", function() + expect(jest.globalEnv).toEqual(expect.any("table")) + expect(globalMocker:isMockGlobalLibrary(jest.globalEnv)).toBe(true) +end) + +-- To show a full list of which MOCKABLE_GLOBALS aren't yet covered by +-- globalEnv, this test is structured as a set of dynamically generated it() +-- blocks. Each it() block is responsible for checking the existence of one +-- of the globalEnv members. The effect of this is to make clear in the unit +-- test output when certain mocks aren't present, labelled clearly with +-- their qualified name and expected type, even if multiple things are +-- missing at once. +describe("globalEnv implements all MOCKABLE_GLOBALS", function() + local function test(mockableGlobals: Object, globalPath: { string }) + for name, mockableGlobal in mockableGlobals do + local nestedPath = table.clone(globalPath) + table.insert(nestedPath, name) + local qualifiedName = table.concat(nestedPath, ".") + + if typeof(mockableGlobal) == "function" then + it(`unmocked function: {qualifiedName}`, function() + local target = jest.globalEnv :: Object + for _, pathPart in nestedPath do + target = target[pathPart] + end + expect(target).toEqual(expect.any("function")) + end) + elseif typeof(mockableGlobal) == "table" then + it(`mockable library: {qualifiedName}`, function() + local target = jest.globalEnv :: Object + for _, pathPart in nestedPath do + target = target[pathPart] + end + expect(globalMocker:isMockGlobalLibrary(target)).toBe(true) + end) + test(mockableGlobal, nestedPath) + end + end + end + test(exports.MOCKABLE_GLOBALS, {}) +end) + +it("globalEnv errors when indexing non-mocked globals", function() + expect(function() + local _ = (jest.globalEnv :: any).notreal + end).toThrow("Jest does not yet support mocking the notreal global") + expect(function() + local _ = (jest.globalEnv :: any).math.notreal + end).toThrow("Jest does not yet support mocking the math.notreal global") +end) diff --git a/src/jest-mock-genv/src/init.lua b/src/jest-mock-genv/src/init.lua new file mode 100644 index 00000000..fc041846 --- /dev/null +++ b/src/jest-mock-genv/src/init.lua @@ -0,0 +1,162 @@ +--!nonstrict +-- ROBLOX NOTE: no upstream +--[[ + * Copyright (c) Roblox Corporation. All rights reserved. + * Licensed under the MIT License (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://opensource.org/licenses/MIT + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. +]] + +local LuauPolyfill = require("@pkg/@jsdotlua/luau-polyfill") +type Object = LuauPolyfill.Object + +local exports = {} + +local GlobalMockerClass = {} + +export type GlobalAutomockFn = { + _isGlobalAutomockFn: true, + _maybeMock: any, + _maybeUnmocked: any, +} +export type GlobalAutomocks = { [string]: GlobalAutomockFn | GlobalAutomocks } +export type GlobalEnvLibrary = { + _isMockGlobalLibrary: true, + _automocksRef: GlobalAutomocks, +} +-- The GlobalEnv type should always look like the MOCKABLE_GLOBALS table; +-- users depend on GlobalEnv for autocomplete and type checking. +export type GlobalEnv = GlobalEnvLibrary & { + print: typeof(print), + warn: typeof(warn), + math: GlobalEnvLibrary & { + random: typeof(math.random), + }, +} +local MOCKABLE_GLOBALS = { + print = print, + warn = warn, + math = { + random = math.random, + }, +} + +export type GlobalMocker = { + isMockGlobalLibrary: (_self: GlobalMocker, object: any) -> boolean, + automocks: GlobalAutomocks, + envObject: GlobalEnv, + currentlyMocked: boolean, +} + +GlobalMockerClass.__index = GlobalMockerClass +function GlobalMockerClass.new(): GlobalMocker + local self = setmetatable({}, GlobalMockerClass) + + self.automocks = self:_createGlobalAutomocks() + self.envObject = self:_createGlobalEnv(self.automocks) + self.currentlyMocked = false + + return (self :: any) :: GlobalMocker +end + +function GlobalMockerClass:isMockGlobalLibrary(object: any): boolean + return typeof(object) == "table" and object._isMockGlobalLibrary == true +end + +function GlobalMockerClass:_createGlobalAutomocks(): GlobalAutomocks + local function implement(mockableGlobals: Object, into: GlobalAutomocks) + for name, mockableGlobal in mockableGlobals do + if typeof(mockableGlobal) == "function" then + into[name] = { + _isGlobalAutomockFn = true, + _maybeMock = nil, + _maybeUnmocked = nil, + } + elseif typeof(mockableGlobal) == "table" then + local subAutomocks = {} + implement(mockableGlobal, subAutomocks) + into[name] = subAutomocks + else + error("Unexpected mockable global type - this is an internal bug") + end + end + end + local automocks = {} + implement(MOCKABLE_GLOBALS, automocks) + return automocks +end + +function GlobalMockerClass:_createGlobalEnv(automocks: GlobalAutomocks): GlobalEnv + local function makeSentinelForLibrary(automocks: GlobalAutomocks, globalPath: { string }) + local library: GlobalEnvLibrary = { + _isMockGlobalLibrary = true, + _automocksRef = automocks, + } + + -- Allow users to access nested libraries like `math`. + for name, automock in automocks do + if typeof(automock) == "table" and not automock._isGlobalAutomockFn then + local libraryGlobalPath = table.clone(globalPath) + table.insert(libraryGlobalPath, name) + library[name] = makeSentinelForLibrary(automock, libraryGlobalPath) + end + end + + -- Users might want to mock functions that don't have an underlying + -- implementation in Jest. Detect that and throw an error here to inform + -- them Jest must explicitly support individual globals to be mocked. + setmetatable(library, { + __index = function(_, name: string) + -- name is actually `unknown` type; the type declaration is a + -- convenient lie so our code type checks without a fuss + if typeof(name) ~= "string" then + error(`Cannot index globalEnv with {name} (expected string)`) + end + + -- Give $$ names like $$typeof a free pass, because they're used + -- internally in some Jest/LuauPolyfill functions, and probably + -- aren't a user accidentally misusing `globalEnv`. + if string.sub(name, 1, 2) == "$$" then + return nil + end + + -- Unmocked functions aren't included in the actual object, so + -- simulate them being included here (where we can dynamically + -- fetch which function to actually return) + local automock = automocks[name] + if typeof(automock) == "table" and automock._isGlobalAutomockFn then + return automock._maybeUnmocked or error("globalEnv has not been initialised by Jest here") + end + + -- In theory, what we want to do is `table.concat` the global + -- path, but including `name` at the end. Instead of doing table + -- manipulation, just implement that with a plain loop. + local qualifiedName = "" + for _, parentName in globalPath do + qualifiedName ..= parentName .. "." + end + qualifiedName ..= name + error(`Jest does not yet support mocking the {qualifiedName} global.`) + end, + }) + + return table.freeze(library) + end + + -- This will match `GlobalEnv` in the end, but it's difficult to + -- statically type check that, so for code cleanliness, just cast to it. + return makeSentinelForLibrary(automocks, {}) :: any +end + +exports.GlobalMocker = GlobalMockerClass +exports.MOCKABLE_GLOBALS = MOCKABLE_GLOBALS + +return exports diff --git a/src/jest-mock-rbx/.npmignore b/src/jest-mock-rbx/.npmignore new file mode 100644 index 00000000..afe7a485 --- /dev/null +++ b/src/jest-mock-rbx/.npmignore @@ -0,0 +1,30 @@ +/.* +/scripts +/docs +/site + +/build +/roblox +/temp + +/*.json +/*.json5 +/*.yml +/*.toml +/*.md +/*.txt +/*.tgz + +**/*.d.lua +**/*.spec.lua +**/*.test.lua +**/tests +**/__tests__ +**/jest.config.lua + +**/*.rbxl +**/*.rbxlx +**/*.rbxl.lock +**/*.rbxlx.lock +**/*.rbxm +**/*.rbxmx diff --git a/src/jest-mock-rbx/README.md b/src/jest-mock-rbx/README.md new file mode 100644 index 00000000..c3cdd314 --- /dev/null +++ b/src/jest-mock-rbx/README.md @@ -0,0 +1,33 @@ +# jest-mock-rbx + +*No upstream. Roblox only.* + +This package implements Jest's data model mocking capabilities for Roblox: + +* `InstanceProxy`, used for constructing objects that act like an existing + instance, except actions can be intercepted and mocked. +* `DataModelMocker`, provides higher-level proxies that can interact with each + other as part of a fake data model. + +--- + +### :pencil2: Notes +* Right now, only a limited subset of data model mocking features are + implemented and available for use. +* `DataModelMocker` should be used for constructing instance mocks, not + `InstanceProxy`. + * The `InstanceProxy` class is a minimal, self-contained implementation with + no special behaviour. + * The `DataModelMocker` class allows proxies to be reused, and provides + useful default mock behaviour like returning proxied descendants or ancestry. +* `InstanceProxy` intentionally only implements some of an `Instance`'s + capabilities. + * It is assumed that the instance proxy is being used on normal, idiomatic + code that would correctly function if given an `Instance`. + * Instance proxies have normal reference-based equality. They are not equal + to the original instance, nor to any other proxies of that instance. + * Instance proxies can't be consumed by functions expecting a "proper + Instance". + * In particular, calls to C such as `x:IsAncestorOf(InstanceProxy)` will + error as these calls are not mockable. + diff --git a/src/jest-mock-rbx/package.json b/src/jest-mock-rbx/package.json new file mode 100644 index 00000000..40217872 --- /dev/null +++ b/src/jest-mock-rbx/package.json @@ -0,0 +1,23 @@ +{ + "name": "@jsdotlua/jest-mock-rbx", + "version": "3.6.1-rc.2", + "repository": { + "type": "git", + "url": "https://github.com/jsdotlua/jest-lua.git", + "directory": "src/jest-mock-rbx" + }, + "license": "MIT", + "main": "src/init.lua", + "scripts": { + "prepare": "npmluau" + }, + "dependencies": { + "@jsdotlua/jest-types": "workspace:^", + "@jsdotlua/luau-polyfill": "^1.2.6" + }, + "devDependencies": { + "@jsdotlua/jest-config": "workspace:^", + "@jsdotlua/jest-globals": "workspace:^", + "npmluau": "^0.1.1" + } +} diff --git a/src/jest-mock-rbx/src/DataModelMocker.lua b/src/jest-mock-rbx/src/DataModelMocker.lua new file mode 100644 index 00000000..e1fe6513 --- /dev/null +++ b/src/jest-mock-rbx/src/DataModelMocker.lua @@ -0,0 +1,61 @@ +--[[ + * Copyright (c) Roblox Corporation. All rights reserved. + * Licensed under the MIT License (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://opensource.org/licenses/MIT + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. +]] +--!strict +-- ROBLOX NOTE: no upstream + +local InstanceProxy = require("./InstanceProxy") +type InstanceProxy = InstanceProxy.InstanceProxy + +export type DataModelMocker = { + mockInstance: (self: DataModelMocker, instance: ClassType & Instance) -> InstanceProxy, +} +type DataModelMocker_private = DataModelMocker & { + _instanceProxies: { [Instance]: InstanceProxy }, +} + +local DataModelMocker = {} +DataModelMocker.__index = DataModelMocker + +function DataModelMocker.mockInstance( + self: DataModelMocker_private, + instance: ClassType & Instance +): InstanceProxy + local proxy = self._instanceProxies[instance] + if proxy == nil then + proxy = InstanceProxy.new(instance) + -- This proxy is never removed from the table, and the strong reference + -- is necessary so that the proxy is remembered even if the tested Luau + -- code doesn't hold a reference for some time. This could cause a + -- memory leak but this mocker is presumably destroyed at the end of the + -- test suite, so this should be OK. + self._instanceProxies[instance] = proxy + --[[ + ROBLOX TODO: mock ancestry/descendants to preserve instance proxies + created in a hierarchy, not important for GetService mocking + ]] + end + return proxy :: any +end + +local exports = {} + +function exports.new(): DataModelMocker + local mocker: DataModelMocker_private = setmetatable({ + _instanceProxies = {}, + }, DataModelMocker) :: any + return mocker +end + +return exports diff --git a/src/jest-mock-rbx/src/InstanceProxy.lua b/src/jest-mock-rbx/src/InstanceProxy.lua new file mode 100644 index 00000000..e755b07d --- /dev/null +++ b/src/jest-mock-rbx/src/InstanceProxy.lua @@ -0,0 +1,115 @@ +--[[ + * Copyright (c) Roblox Corporation. All rights reserved. + * Licensed under the MIT License (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://opensource.org/licenses/MIT + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. +]] +--!strict +-- ROBLOX NOTE: no upstream + +-- ROBLOX TODO: type checking lie - this should be replaced with something more +-- representative of the actual spy type when Luau has stable support for +-- metatables and type functions. For now, mimic the given instance for good +-- autocomplete support. +export type Spied = ClassType + +type Function = (...any) -> ...any + +type MockedMethodData = { + methodFn: Function, +} + +export type InstanceProxy = { + spy: Spied, + controls: ProxyControls, +} + +export type ProxyControls = { + mockMethod: (self: ProxyControls, name: string, method: Function) -> () -> (), + --[[ + ROBLOX TODO: + * mock property / event / callback fields + * figure out a strategy for mocking descendants and ancestry nicely + ]] +} +type ProxyControls_private = ProxyControls & { + _mockedMethods: { [string]: MockedMethodData }, +} + +local function makeSpyInstance( + original: ClassType & Instance, + controls: ProxyControls_private +): Spied + local meta = {} + local spied = setmetatable({}, meta) + -- Freeze to ensure the table is empty & metamethods run. + table.freeze(spied) + + function meta:__index(key: string): unknown + local value = (original :: any)[key] + if typeof(value) == "function" then + -- instance method + local mocked = controls._mockedMethods[key] + return if mocked == nil + then function(_, ...) + return value(original, ...) + end + else mocked.methodFn + else + -- instance property or event + return value + end + end + + function meta:__newindex(key: unknown, value: unknown): () + (original :: any)[key] = value + end + + function meta:__tostring(): string + return tostring(meta.__index(self, "Name")) + end + + meta.__metatable = "The metatable is locked" + + -- ROBLOX TODO: type checking lie + return spied :: any +end + +local ProxyControls = {} +ProxyControls.__index = ProxyControls + +function ProxyControls.mockMethod(self: ProxyControls_private, name: string, method: Function): () -> () + local data: MockedMethodData = { + methodFn = method, + } + self._mockedMethods[name] = data + return function() + -- don't overwrite a mock that was added in the meantime + if self._mockedMethods[name] == data then + self._mockedMethods[name] = nil + end + end +end + +local exports = {} + +function exports.new(original: ClassType & Instance): InstanceProxy + local controls: ProxyControls_private = setmetatable({ + _mockedMethods = {}, + }, ProxyControls) :: any + + return { + spy = makeSpyInstance(original, controls), + controls = controls, + } +end + +return exports diff --git a/src/jest-mock-rbx/src/__tests__/DataModelMocker.spec.lua b/src/jest-mock-rbx/src/__tests__/DataModelMocker.spec.lua new file mode 100644 index 00000000..a1172521 --- /dev/null +++ b/src/jest-mock-rbx/src/__tests__/DataModelMocker.spec.lua @@ -0,0 +1,56 @@ +--[[ + * Copyright (c) Roblox Corporation. All rights reserved. + * Licensed under the MIT License (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://opensource.org/licenses/MIT + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. +]] +--!strict +-- ROBLOX NOTE: no upstream +local JestGlobals = require("@pkg/@jsdotlua/jest-globals") +local expect = JestGlobals.expect +local describe = JestGlobals.describe +local test = JestGlobals.test +local beforeEach = JestGlobals.beforeEach + +local DataModelMocker = require("../DataModelMocker") + +-- An example instance to be proxied by tests below. +local original +local mocker: DataModelMocker.DataModelMocker +beforeEach(function() + original = Instance.new("TextLabel") + original.Name = "The Example Instance" + original.BackgroundColor3 = Color3.new(1, 1, 1) + original.Size = UDim2.fromOffset(200, 50) + original.Text = "I am a good example!" + original.TextColor3 = Color3.new(0, 0, 0) + original.Font = Enum.Font.BuilderSans + + local corner = Instance.new("UICorner") + corner.CornerRadius = UDim.new(0, 16) + corner.Parent = original + + mocker = DataModelMocker.new() +end) + +describe("mockInstance()", function() + test("returns a complete instance proxy", function() + local proxy = mocker:mockInstance(original) + + expect(proxy).toEqual(expect.any("table")) + expect(proxy.spy.Name).toEqual(original.Name) + expect(proxy.controls.mockMethod).toEqual(expect.any("function")) + end) + + test("caches proxies when called again", function() + expect(mocker:mockInstance(original) == mocker:mockInstance(original)).toBeTruthy() + end) +end) diff --git a/src/jest-mock-rbx/src/__tests__/InstanceProxy.spec.lua b/src/jest-mock-rbx/src/__tests__/InstanceProxy.spec.lua new file mode 100644 index 00000000..7f2a7c02 --- /dev/null +++ b/src/jest-mock-rbx/src/__tests__/InstanceProxy.spec.lua @@ -0,0 +1,180 @@ +--[[ + * Copyright (c) Roblox Corporation. All rights reserved. + * Licensed under the MIT License (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://opensource.org/licenses/MIT + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. +]] +--!strict +-- ROBLOX NOTE: no upstream +local JestGlobals = require("@pkg/@jsdotlua/jest-globals") +local expect = JestGlobals.expect +local describe = JestGlobals.describe +local test = JestGlobals.test +local beforeEach = JestGlobals.beforeEach + +local InstanceProxy = require("../InstanceProxy") + +-- An example instance to be proxied by tests below. +local original +beforeEach(function() + original = Instance.new("TextLabel") + original.Name = "The Example Instance" + original.BackgroundColor3 = Color3.new(1, 1, 1) + original.Size = UDim2.fromOffset(200, 50) + original.Text = "I am a good example!" + original.TextColor3 = Color3.new(0, 0, 0) + original.Font = Enum.Font.BuilderSans + + local corner = Instance.new("UICorner") + corner.CornerRadius = UDim.new(0, 16) + corner.Parent = original +end) + +describe("spy transparency", function() + test("properties can be read", function() + local spy = InstanceProxy.new(original).spy + + expect(spy.Name).toBe("The Example Instance") + + original.Name = "Pamela" + expect(spy.Name).toBe("Pamela") + end) + + test("properties can be written", function() + local spy = InstanceProxy.new(original).spy + + expect(function() + spy.Name = "Susan" + end).never.toThrow() + + expect(spy.Name).toBe("Susan") + expect(original.Name).toBe("Susan") + end) + + test("methods can be called", function() + local spy = InstanceProxy.new(original).spy + + expect(spy:IsA("GuiObject")).toBe(true) + end) + + test("events can be listened to", function(_, done) + local spy = InstanceProxy.new(original).spy + + local numFires = 0 + + expect(function() + spy.Changed:Connect(function() + numFires += 1 + end) + end).never.toThrow() + + expect(numFires).toBe(0) + + original.Name = "Pamela" + -- in Deferred SignalBehaviour, this is required + task.defer(function() + expect(numFires).toBe(1) + done() + end) + end) + + test("callbacks can be assigned and invoked", function() + local bindable = Instance.new("BindableFunction") + local spy = InstanceProxy.new(bindable).spy + + local accumulator = 0 + + expect(function() + spy.OnInvoke = function(number) + accumulator += number + end + end).never.toThrow() + + bindable:Invoke(24) + expect(accumulator).toBe(24) + end) + + test("tostring behaviour", function() + local spy = InstanceProxy.new(original).spy + + expect(tostring(spy)).toEqual(tostring(original)) + end) + + test("correctly-locked metatable", function() + local spy = InstanceProxy.new(original).spy + + expect(getmetatable(spy :: any)).toEqual(getmetatable(original :: any)) + end) +end) + +describe("mocking behaviour", function() + test("methods can be mocked and unmocked", function() + local proxy = InstanceProxy.new(original) + + local unmock = proxy.controls:mockMethod("IsA", function(_, class: string) + return class == "Sausage Roll" + end) + + expect(proxy.spy:IsA("GuiObject")).toBe(false) + expect(proxy.spy:IsA("Sausage Roll" :: any)).toBe(true) + + expect(original:IsA("GuiObject")).toBe(true) + + unmock() + + expect(proxy.spy:IsA("GuiObject")).toBe(true) + expect(proxy.spy:IsA("Sausage Roll" :: any)).toBe(false) + end) + + test("method unmock is ignored after multiple calls", function() + local proxy = InstanceProxy.new(original) + + local unmock = proxy.controls:mockMethod("IsA", function(_, class: string) + return class == "Sausage Roll" + end) + + expect(function() + unmock() + unmock() + unmock() + end).never.toThrow() + end) + + test("method unmock is ignored after being replaced", function() + local proxy = InstanceProxy.new(original) + + local unmockSausage = proxy.controls:mockMethod("IsA", function(_, class: string) + return class == "Sausage Roll" + end) + + proxy.controls:mockMethod("IsA", function(_, class: string) + return class == "Egg Sandwich" + end) + + unmockSausage() + + expect(proxy.spy:IsA("GuiObject")).toBe(false) + expect(proxy.spy:IsA("Sausage Roll" :: any)).toBe(false) + expect(proxy.spy:IsA("Egg Sandwich" :: any)).toBe(true) + end) + + test("method mock passes correct arguments", function() + local proxy = InstanceProxy.new(original) + + proxy.controls:mockMethod("IsA", function(self, class) + expect(self).never.toEqual(original) + expect(self).toEqual(proxy.spy) + expect(class).toEqual(expect.any("string")) + end) + + proxy.spy:IsA("GuiObject") + end) +end) diff --git a/src/jest-mock-rbx/src/init.lua b/src/jest-mock-rbx/src/init.lua new file mode 100644 index 00000000..d75fa46d --- /dev/null +++ b/src/jest-mock-rbx/src/init.lua @@ -0,0 +1,31 @@ +--[[ + * Copyright (c) Roblox Corporation. All rights reserved. + * Licensed under the MIT License (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://opensource.org/licenses/MIT + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. +]] +--!strict +-- ROBLOX NOTE: no upstream + +local InstanceProxy = require("./InstanceProxy") +local DataModelMocker = require("./DataModelMocker") + +export type InstanceProxy = InstanceProxy.InstanceProxy +export type Spied = InstanceProxy.Spied +export type ProxyControls = InstanceProxy.ProxyControls + +export type DataModelMocker = DataModelMocker.DataModelMocker + +local exports = {} + +exports.DataModelMocker = DataModelMocker + +return exports diff --git a/src/jest-mock/README.md b/src/jest-mock/README.md index 277bb013..09362d47 100644 --- a/src/jest-mock/README.md +++ b/src/jest-mock/README.md @@ -1,10 +1,8 @@ # jest-mock -Status: :hammer: In Progress +Upstream: https://github.com/facebook/jest/tree/v27.4.7/packages/jest-mock -Source: https://github.com/facebook/jest/tree/v27.4.7/packages/jest-mock - -Version: v27.4.7 +This package implements the various function and module mocking capabilities used by Jest. You can find its documentation in the [Jest documentation](https://roblox.github.io/jest-roblox-internal). --- @@ -16,13 +14,3 @@ Version: v27.4.7 * `clearAllMocks()` * `resetAllMocks()` * `restoreAllMocks()` - -### :x: Excluded -``` -``` - -### :package: [Dependencies](https://github.com/facebook/jest/blob/v27.4.7/packages/jest-mock/package.json) -| Package | Version | Status | Notes | -| ------------- | ------- | ------------------------- | ----- | -| `@jest/types` | 27.0.6 | :heavy_check_mark: Ported | | -| `@types/node` | * | :x: Will not port | | diff --git a/src/jest-mock/package.json b/src/jest-mock/package.json index 1dc5ab93..aacf348f 100644 --- a/src/jest-mock/package.json +++ b/src/jest-mock/package.json @@ -12,9 +12,12 @@ "prepare": "npmluau" }, "dependencies": { + "@jsdotlua/jest-mock-genv": "workspace:^", + "@jsdotlua/jest-types": "workspace:^", "@jsdotlua/luau-polyfill": "^1.2.6" }, "devDependencies": { + "@jsdotlua/jest-config": "workspace:^", "@jsdotlua/jest-globals": "workspace:^", "npmluau": "^0.1.1" } diff --git a/src/jest-mock/src/__tests__/__snapshots__/roblox.spec.snap.lua b/src/jest-mock/src/__tests__/__snapshots__/roblox.spec.snap.lua new file mode 100644 index 00000000..ae06316e --- /dev/null +++ b/src/jest-mock/src/__tests__/__snapshots__/roblox.spec.snap.lua @@ -0,0 +1,6 @@ +-- Jest Roblox Snapshot v1, http://roblox.github.io/jest-roblox-internal/snapshot-testing +local exports = {} +exports[ [=[spyOn supported types does not allow tables with locked callable metatables 1]=] ] = [=[ +"Cannot spy the foo property because it cannot be cloned. (protected metatable)"]=] + +return exports diff --git a/src/jest-mock/src/__tests__/index.spec.lua b/src/jest-mock/src/__tests__/index.spec.lua index 4be75edc..4b1dad7f 100644 --- a/src/jest-mock/src/__tests__/index.spec.lua +++ b/src/jest-mock/src/__tests__/index.spec.lua @@ -7,20 +7,49 @@ -- * -- */ -local ModuleMocker = require("../init").ModuleMocker local JestGlobals = require("@pkg/@jsdotlua/jest-globals") local expect = JestGlobals.expect local describe = JestGlobals.describe local it = JestGlobals.it local beforeEach = JestGlobals.beforeEach +local jest = JestGlobals.jest +local JestConfig = require("@pkg/@jsdotlua/jest-config") + +local LuauPolyfill = require("@pkg/@jsdotlua/luau-polyfill") +local Error = LuauPolyfill.Error + +local parentModule = require("../init") +local ModuleMocker = parentModule.ModuleMocker +-- ROBLOX deviation START: can't provide these globally +-- local fn = parentModule.fn +-- local mocked = parentModule.mocked +-- local spyOn = parentModule.spyOn local moduleMocker beforeEach(function() - moduleMocker = ModuleMocker.new() + moduleMocker = ModuleMocker.new(JestConfig.projectDefaults) end) +-- ROBLOX deviation END describe("moduleMocker", function() + -- ROBLOX deviation START: can't provide these globally + -- local moduleMocker + -- beforeEach(function() + -- moduleMocker = ModuleMocker.new() + -- end) + -- ROBLOX deviation END + + --[[ + ROBLOX deviation: skipped code: + original code lines 25 - 119 + ]] + describe("generateFromMetadata", function() + --[[ + ROBLOX deviation: skipped code: + original code lines 122 - 410 + ]] + describe("mocked functions", function() it("tracks calls to mocks", function() local fn = moduleMocker:fn() @@ -73,7 +102,6 @@ describe("moduleMocker", function() expect(fn.mock.contexts[4]).toBe(nil) fn(nil) expect(fn.mock.contexts[5]).toBe(nil); - (function(...) return fn(nil, ...) end)() @@ -97,6 +125,12 @@ describe("moduleMocker", function() fn.mockClear() expect(fn.mock.calls).toEqual({}) + + fn("a", "b", "c") + + expect(fn.mock.calls).toEqual({ { "a", "b", "c" } }) + + expect(fn()).toEqual("abcd") end) it("supports clearing mocks", function() @@ -204,7 +238,7 @@ describe("moduleMocker", function() end) -- ROBLOX deviation: test is itSKIPped because we currently don't - -- implement this ability to inspect functionArity + -- preserve function arity for mocked functions it.skip("maintains function arity", function() local mockFunctionArity1 = moduleMocker:fn(function(x) return x @@ -216,82 +250,577 @@ describe("moduleMocker", function() expect(#mockFunctionArity1).toBe(1) expect(#mockFunctionArity2).toBe(2) end) + end) - -- ROBLOX deviation: tests commented out for now, not yet implemented spyOn - -- it('mocks the method in the passed object itself', function() - -- local parent = {func = function() return 'abcd' end} - -- local child = Object.create(parent) + it("mocks the method in the passed object itself", function() + local parent = { + func = function() + return "abcd" + end, + } + -- ROBLOX deviation: use metatables for prototype-like inheritance + local child = setmetatable({}, { __index = parent }) - -- moduleMocker.spyOn(child, 'func').mockReturnValue('efgh') + moduleMocker:spyOn(child, "func").mockReturnValue("efgh") - -- expect(child['func']).never.toBe(nil) - -- expect(child.func()).toEqual('efgh') - -- expect(parent.func()).toEqual('abcd') - -- end) + -- ROBLOX deviation: use rawget to access through metatable + expect(rawget(child :: any, "func")).never.toBeNil() + expect(child.func()).toEqual("efgh") + expect(parent.func()).toEqual("abcd") + end) + + it("should delete previously inexistent methods when restoring", function() + local parent = { + func = function() + return "abcd" + end, + } + -- ROBLOX deviation: use metatables for prototype-like inheritance + local child = setmetatable({}, { __index = parent }) - -- it('should delete previously inexistent methods when restoring', function() - -- local parent = {func = function() return 'abcd' end} - -- local child = Object.create(parent) + moduleMocker:spyOn(child, "func").mockReturnValue("efgh") - -- moduleMocker.spyOn(child, 'func').mockReturnValue('efgh') + moduleMocker:restoreAllMocks() + expect(child.func()).toEqual("abcd") - -- moduleMocker.restoreAllMocks() - -- expect(child.func()).toEqual('abcd') + moduleMocker:spyOn(parent, "func").mockReturnValue("jklm") - -- moduleMocker.spyOn(parent, 'func').mockReturnValue('jklm') + -- ROBLOX deviation: use rawget instead of hasOwnProperty + expect(rawget(child :: any, "func")).toBeNil() + expect(child.func()).toEqual("jklm") + end) - -- expect(child.hasOwnProperty('func')).toBe(false) - -- expect(child.func()).toEqual('jklm') - -- end) + it("supports mock value returning nil", function() + local obj = { + func = function() + return "some text" + end, + } - -- it('supports mock value returning undefined', function() - -- local obj = { - -- func = function() return 'some text' end - -- } + moduleMocker:spyOn(obj, "func").mockReturnValue(nil) - -- moduleMocker.spyOn(obj, 'func').mockReturnValue(undefined) + expect(obj.func()).never.toEqual("some text") + end) - -- expect(obj.func()).never.toEqual('some text') - -- end) + it("supports mock value once returning nil", function() + local obj = { + func = function() + return "some text" + end, + } - -- it('supports mock value once returning undefined', function() - -- local obj = { - -- func = function() return 'some text' end, - -- } + moduleMocker:spyOn(obj, "func").mockReturnValueOnce(nil) - -- moduleMocker.spyOn(obj, 'func').mockReturnValueOnce(undefined) + expect(obj.func()).never.toEqual("some text") + end) - -- expect(obj.func()).never.toEqual('some text') - -- end) + it("mockReturnValueOnce mocks value just once", function() + local fake = moduleMocker:fn(function(a: number) + return a + 2 + end) + fake.mockReturnValueOnce(42) + expect(fake(2)).toEqual(42) + expect(fake(2)).toEqual(4) + end) - it("mockReturnValueOnce mocks value just once", function() - local fake = moduleMocker:fn(function(a: number) - return a + 2 + --[[ + ROBLOX deviation: skipped code: + original code lines 647 - 692 + ]] + + describe("return values", function() + it("tracks return values", function() + local fn = moduleMocker:fn(function(x: number) + return x * 2 + end) + expect(fn.mock.results).toEqual({}) + fn(1) + fn(2) + expect(fn.mock.results).toEqual({ + { type = "return", value = 2 }, + { type = "return", value = 4 }, + } :: { any }) + end) + + it("tracks mocked return values", function() + local fn = moduleMocker:fn(function(x: number) + return x * 2 end) - fake.mockReturnValueOnce(42) - expect(fake(2)).toEqual(42) - expect(fake(2)).toEqual(4) + fn.mockReturnValueOnce("MOCKED!") + fn(1) + fn(2) + expect(fn.mock.results).toEqual({ + { type = "return", value = "MOCKED!" }, + { type = "return", value = 4 }, + } :: { any }) + end) + + it("supports resetting return values", function() + local fn = moduleMocker:fn(function(x: number) + return x * 2 + end) + expect(fn.mock.results).toEqual({}) + fn(1) + fn(2) + expect(fn.mock.results).toEqual({ + { type = "return", value = 2 }, + { type = "return", value = 4 }, + }) + fn.mockReset() + expect(fn.mock.results).toEqual({}) + end) + end) + + it("tracks thrown errors without interfering with other tracking", function() + local error_ = Error.new("ODD!") + local fn = moduleMocker:fn(function(x: number, y: number) + -- multiply params + local result = x * y + if result % 2 == 1 then + -- throw error if result is odd + error(error_) + else + return result + end + end) + expect(fn(2, 4)).toBe(8) -- Mock still throws the error even though it was internally + -- caught and recorded + expect(function() + fn(3, 5) + end).toThrow("ODD!") + expect(fn(6, 3)).toBe(18) -- All call args tracked + expect(fn.mock.calls).toEqual({ { 2, 4 }, { 3, 5 }, { 6, 3 } }) -- Results are tracked + expect(fn.mock.results).toEqual({ + { type = "return", value = 8 }, + { type = "throw", value = error_ }, + { type = "return", value = 18 }, + } :: { any }) + end) + + -- ROBLOX deviation: use `nil` instead of undefined for test + it("a call that throws nil is tracked properly", function() + local fn = moduleMocker:fn(function() + -- eslint-disable-next-line no-throw-literal + error(nil) + end) + pcall(function() + fn(2, 4) + end) + expect(fn.mock.calls).toEqual({ { 2, 4 } }) -- Results are tracked + expect(fn.mock.results).toEqual({ { type = "throw", value = nil } }) + end) + + it("results of recursive calls are tracked properly", function() + -- sums up all integers from 0 -> value, using recursion + local fn: any + -- ROBLOX deviation; separate declaration from assignment for + fn = moduleMocker:fn(function(value: number) + if value == 0 then + return 0 + else + return value + fn(value - 1) + end + end) + fn(4) -- All call args tracked + expect(fn.mock.calls).toEqual({ { 4 }, { 3 }, { 2 }, { 1 }, { 0 } }) -- Results are tracked + -- (in correct order of calls, rather than order of returns) + expect(fn.mock.results).toEqual({ + { type = "return", value = 10 }, + { type = "return", value = 6 }, + { type = "return", value = 3 }, + { type = "return", value = 1 }, + { type = "return", value = 0 }, + }) + end) + + it("test results of recursive calls from within the recursive call", function() + -- sums up all integers from 0 -> value, using recursion + local fn: any + -- ROBLOX deviation; separate declaration from assignment for + fn = moduleMocker:fn(function(value: number) + if value == 0 then + return 0 + else + local recursiveResult = fn(value - 1) + if value == 3 then + -- All recursive calls have been made at this point. + expect(fn.mock.calls).toEqual({ { 4 }, { 3 }, { 2 }, { 1 }, { 0 } }) -- But only the last 3 calls have returned at this point. + expect(fn.mock.results).toEqual({ + { type = "incomplete", value = nil }, + { type = "incomplete", value = nil }, + { type = "return", value = 3 }, + { type = "return", value = 1 }, + { type = "return", value = 0 }, + } :: { any }) + end + return value + recursiveResult + end + end) + fn(4) + end) + + it("call mockClear inside recursive mock", function() + -- sums up all integers from 0 -> value, using recursion + local fn: any + -- ROBLOX deviation; separate declaration from assignment for + fn = moduleMocker:fn(function(value: number) + if value == 3 then + fn:mockClear() + end + if value == 0 then + return 0 + else + return value + fn(value - 1) + end + end) + fn(3) -- All call args (after the call that cleared the mock) are tracked + expect(fn.mock.calls).toEqual({ { 2 }, { 1 }, { 0 } }) -- Results (after the call that cleared the mock) are tracked + expect(fn.mock.results).toEqual({ + { type = "return", value = 3 }, + { type = "return", value = 1 }, + { type = "return", value = 0 }, + }) + end) + + describe("invocationCallOrder", function() + it("tracks invocationCallOrder made by mocks", function() + local fn1 = moduleMocker:fn() + expect(fn1.mock.invocationCallOrder).toEqual({}) + fn1(1, 2, 3) + expect(fn1.mock.invocationCallOrder[ + 1 --[[ ROBLOX adaptation: added 1 to array index ]] + ]).toBe(1) + fn1("a", "b", "c") + expect(fn1.mock.invocationCallOrder[ + 2 --[[ ROBLOX adaptation: added 1 to array index ]] + ]).toBe(2) + fn1(1, 2, 3) + expect(fn1.mock.invocationCallOrder[ + 3 --[[ ROBLOX adaptation: added 1 to array index ]] + ]).toBe(3) + local fn2 = moduleMocker:fn() + expect(fn2.mock.invocationCallOrder).toEqual({}) + fn2("d", "e", "f") + expect(fn2.mock.invocationCallOrder[ + 1 --[[ ROBLOX adaptation: added 1 to array index ]] + ]).toBe(4) + fn2(4, 5, 6) + expect(fn2.mock.invocationCallOrder[ + 2 --[[ ROBLOX adaptation: added 1 to array index ]] + ]).toBe(5) + end) + + it("supports clearing mock invocationCallOrder", function() + local fn = moduleMocker:fn() + expect(fn.mock.invocationCallOrder).toEqual({}) + fn(1, 2, 3) + expect(fn.mock.invocationCallOrder).toEqual({ 1 }) + fn.mockReturnValue("abcd") + fn.mockClear() + expect(fn.mock.invocationCallOrder).toEqual({}) + fn("a", "b", "c") + expect(fn.mock.invocationCallOrder).toEqual({ 2 }) + expect(fn()).toEqual("abcd") end) - it("mocks a function with return value of nil", function() - local fn = moduleMocker:fn(function() - return nil + it("supports clearing all mocks invocationCallOrder", function() + local fn1 = moduleMocker:fn() + fn1.mockImplementation(function() + return "abcd" end) - expect(fn()).toEqual(nil) - expect(fn.mock.calls).toEqual({ {} }) + fn1(1, 2, 3) + expect(fn1.mock.invocationCallOrder).toEqual({ 1 }) + local fn2 = moduleMocker:fn() + fn2.mockReturnValue("abcde") + fn2("a", "b", "c", "d") + expect(fn2.mock.invocationCallOrder).toEqual({ 2 }) + moduleMocker:clearAllMocks() + expect(fn1.mock.invocationCallOrder).toEqual({}) + expect(fn2.mock.invocationCallOrder).toEqual({}) + expect(fn1()).toEqual("abcd") + expect(fn2()).toEqual("abcde") + end) + + -- ROBLOX deviation START: skip non-applicable test + -- it("handles a property called `prototype`", function() + -- local mock = + -- moduleMocker:generateFromMetadata(moduleMocker:getMetadata({ prototype = 1 })) + -- expect(mock.prototype).toBe(1) + -- end) + -- ROBLOX deviation END + end) + end) + + describe("getMockImplementation", function() + it("should mock calls to a mock function", function() + local mockFn = moduleMocker:fn() + mockFn.mockImplementation(function() + return "Foo" end) + expect(typeof(mockFn.getMockImplementation())).toBe("function") + expect(mockFn.getMockImplementation()()).toBe("Foo") end) end) + describe("mockImplementationOnce", function() + -- ROBLOX deviation START: disable Module constructor test + -- it("should mock constructor", function() + -- local mock1 = jest.fn() + -- local mock2 = jest.fn() + -- local Module = jest.fn(function() + -- return { someFn = mock1 } + -- end) + -- local function testFn() + -- local m = Module.new() + -- m:someFn() + -- end + -- Module:mockImplementationOnce(function() + -- return { someFn = mock2 } + -- end) + -- testFn() + -- expect(mock2).toHaveBeenCalled() + -- expect(mock1)["not"].toHaveBeenCalled() + -- testFn() + -- expect(mock1).toHaveBeenCalled() + -- end) + -- ROBLOX deviation END + + it("should mock single call to a mock function", function() + local mockFn = moduleMocker:fn() + mockFn + .mockImplementationOnce(function() + return "Foo" + end) + .mockImplementationOnce(function() + return "Bar" + end) + expect(mockFn()).toBe("Foo") + expect(mockFn()).toBe("Bar") + expect(mockFn()).toBeUndefined() + end) + + it("should fallback to default mock function when no specific mock is available", function() + local mockFn = moduleMocker:fn() + mockFn + .mockImplementationOnce(function() + return "Foo" + end) + .mockImplementationOnce(function() + return "Bar" + end) + .mockImplementation(function() + return "Default" + end) + expect(mockFn()).toBe("Foo") + expect(mockFn()).toBe("Bar") + expect(mockFn()).toBe("Default") + expect(mockFn()).toBe("Default") + end) + end) + + it("mockReturnValue does not override mockImplementationOnce", function() + local mockFn = jest.fn().mockReturnValue(1).mockImplementationOnce(function() + return 2 + end) + expect(mockFn()).toBe(2) + expect(mockFn()).toBe(1) + end) + + it("mockImplementation resets the mock", function() + local fn = jest.fn() + expect(fn()).toBeNil() + fn.mockReturnValue("returnValue") + fn.mockImplementation(function() + return "foo" + end) + expect(fn()).toBe("foo") + end) + + it("should recognize a mocked function", function() + local mockFn = moduleMocker:fn() + expect(moduleMocker:isMockFunction(function() end)).toBe(false) + expect(moduleMocker:isMockFunction(mockFn)).toBe(true) + end) + + it("default mockName is jest.fn()", function() + local fn = jest.fn() + expect(fn.getMockName()).toBe("jest.fn()") + end) + + it("mockName sets the mock name", function() + local fn = jest.fn() + fn.mockName("myMockFn") + expect(fn.getMockName()).toBe("myMockFn") + end) + + it("jest.fn should provide the correct lastCall", function() + local mock = jest.fn() + expect(mock.mock).never.toHaveProperty("lastCall") + mock("first") + mock("second") + mock("last", "call") + expect(mock).toHaveBeenLastCalledWith("last", "call") + expect(mock.mock.lastCall).toEqual({ "last", "call" }) + end) + + it("lastCall gets reset by mockReset", function() + local mock = jest.fn() + mock("first") + mock("last", "call") + expect(mock.mock.lastCall).toEqual({ "last", "call" }) + mock.mockReset() + expect(mock.mock).never.toHaveProperty("lastCall") + end) + + it("mockName gets reset by mockReset", function() + local fn = jest.fn() + expect(fn.getMockName()).toBe("jest.fn()") + fn.mockName("myMockFn") + expect(fn.getMockName()).toBe("myMockFn") + fn.mockReset() + expect(fn.getMockName()).toBe("jest.fn()") + end) + + it("mockName gets reset by mockRestore", function() + local fn = jest.fn() + expect(fn.getMockName()).toBe("jest.fn()") + fn.mockName("myMockFn") + expect(fn.getMockName()).toBe("myMockFn") + fn.mockRestore() + expect(fn.getMockName()).toBe("jest.fn()") + end) + + it("mockName is not reset by mockClear", function() + local fn = jest.fn(function() + return false + end) + fn.mockName("myMockFn") + expect(fn.getMockName()).toBe("myMockFn") + fn.mockClear() + expect(fn.getMockName()).toBe("myMockFn") + end) + + describe("spyOn", function() + it("should work", function() + local isOriginalCalled = false + local originalCallThis + local originalCallArguments: { any }? + local obj = { + -- ROBLOX deviation START: use ... for args + method = function(self, ...) + isOriginalCalled = true + originalCallThis = self + originalCallArguments = table.pack(...) + end, + -- ROBLOX deviation END + } + local spy = moduleMocker:spyOn(obj, "method") + local thisArg = { this = true } + local firstArg = { first = true } + local secondArg = { second = true } + obj.method(thisArg, firstArg, secondArg) + expect(isOriginalCalled).toBe(true) + expect(originalCallThis).toBe(thisArg) + assert(originalCallArguments, "luau narrow") + expect(#originalCallArguments).toBe(2) + expect(originalCallArguments[ + 1 --[[ ROBLOX adaptation: added 1 to array index ]] + ]).toBe(firstArg) + expect(originalCallArguments[ + 2 --[[ ROBLOX adaptation: added 1 to array index ]] + ]).toBe(secondArg) + expect(spy).toHaveBeenCalled() + isOriginalCalled = false + originalCallThis = nil + originalCallArguments = nil + spy.mockRestore() + obj.method(thisArg, firstArg, secondArg) + expect(isOriginalCalled).toBe(true) + expect(originalCallThis).toBe(thisArg) + assert(originalCallArguments, "luau narrow") + expect(#originalCallArguments).toBe(2) + expect(originalCallArguments[ + 1 --[[ ROBLOX adaptation: added 1 to array index ]] + ]).toBe(firstArg) + expect(originalCallArguments[ + 2 --[[ ROBLOX adaptation: added 1 to array index ]] + ]).toBe(secondArg) + expect(spy).never.toHaveBeenCalled() + end) + + it("should throw on invalid input", function() + expect(function() + moduleMocker:spyOn(nil, "method") + end).toThrow() + expect(function() + moduleMocker:spyOn({}, "method") + end).toThrow() + expect(function() + moduleMocker:spyOn({ method = 10 }, "method") + end).toThrow() + end) + + it("supports restoring all spies", function() + local methodOneCalls = 0 + local methodTwoCalls = 0 + local obj = { + methodOne = function(self) + methodOneCalls += 1 + end, + methodTwo = function(self) + methodTwoCalls += 1 + end, + } + local spy1 = moduleMocker:spyOn(obj, "methodOne") + local spy2 = moduleMocker:spyOn(obj, "methodTwo") -- First, we call with the spies: both spies and both original functions + -- should be called. + obj:methodOne() + obj:methodTwo() + expect(methodOneCalls).toBe(1) + expect(methodTwoCalls).toBe(1) + expect(#spy1.mock.calls).toBe(1) + expect(#spy2.mock.calls).toBe(1) + moduleMocker:restoreAllMocks() -- Then, after resetting all mocks, we call methods again. Only the real + -- methods should bump their count, not the spies. + obj:methodOne() + obj:methodTwo() + expect(methodOneCalls).toBe(2) + expect(methodTwoCalls).toBe(2) + expect(#spy1.mock.calls).toBe(1) + expect(#spy2.mock.calls).toBe(1) + end) + + --[[ + ROBLOX deviation: skipped code (getters & prototypes): + original code lines 1250 - 1300 + ]] + end) + --[[ + ROBLOX deviation: skipped code (spyOnProperty not supported): + original code lines 1098 - 1307 + ]] + + -- ROBLOX deviation START: add additional test for luau case + it("mocks a function with return value of nil", function() + local fn = moduleMocker:fn(function() + return nil + end) + expect(fn()).toEqual(nil) + expect(fn.mock.calls).toEqual({ {} }) + end) + -- ROBLOX deviation END end) describe("mocked", function() it("should return unmodified input", function() local subject = {} + -- ROBLOX deviation: can't provide these globally expect(moduleMocker:mocked(subject)).toBe(subject) end) end) ---[[ - ROBLOX deviation: skipped code: - original code lines 1462 - 1467 - ]] +it("`fn` and `spyOn` do not throw", function() + expect(function() + moduleMocker:fn() + moduleMocker:spyOn({ apple = function() end }, "apple") + end).never.toThrow() +end) diff --git a/src/jest-mock/src/__tests__/roblox.spec.lua b/src/jest-mock/src/__tests__/roblox.spec.lua index 231e24a5..42529d6e 100644 --- a/src/jest-mock/src/__tests__/roblox.spec.lua +++ b/src/jest-mock/src/__tests__/roblox.spec.lua @@ -12,17 +12,26 @@ * See the License for the specific language governing permissions and * limitations under the License. ]] +--!strict -- ROBLOX NOTE: no upstream -local ModuleMocker = require("../init").ModuleMocker +local LuauPolyfill = require("@pkg/@jsdotlua/luau-polyfill") +type Object = LuauPolyfill.Object + +local exports = require("../init") +local ModuleMocker = exports.ModuleMocker local JestGlobals = require("@pkg/@jsdotlua/jest-globals") +local jest = JestGlobals.jest local expect = JestGlobals.expect local it = JestGlobals.it +local describe = JestGlobals.describe local beforeEach = JestGlobals.beforeEach +local JestConfig = require("@pkg/@jsdotlua/jest-config") + local moduleMocker beforeEach(function() - moduleMocker = ModuleMocker.new() + moduleMocker = ModuleMocker.new(JestConfig.projectDefaults) end) it("mock return chaining", function() @@ -57,3 +66,228 @@ it("returns a function as the second return value", function() expect(mockFn()).toBe(true) expect(mock).toHaveLastReturnedWith(true) end) + +-- These tests are placed here rather than in JestMockGenv because they require +-- use of the ModuleMocker functions. +describe("global mocking & spying", function() + it("globalEnv can spy on top-level global functions", function() + local mockPrint = moduleMocker:spyOn(jest.globalEnv, "print") + mockPrint.mockReturnValueOnce("abcde") + + local print2 = print :: any -- satisfy the type checker + local returnValue = print2("This is an intentional print from a unit test") + local callsAfter = #mockPrint.mock.calls + + expect(callsAfter).toBe(1) + expect(returnValue).toBe("abcde") + + mockPrint.mockReset() + end) + + it("globalEnv can spy on nested global functions", function() + local mockRand = moduleMocker:spyOn(jest.globalEnv.math, "random") + mockRand.mockReturnValueOnce("abcde") + + local random2 = math.random :: any -- satisfy the type checker + local returnValue = random2() + local callsAfter = #mockRand.mock.calls + + expect(callsAfter).toBe(1) + expect(returnValue).toBe("abcde") + + mockRand.mockReset() + end) + + it("globalEnv unmocked functions bypass mock impls", function() + local mockRand = moduleMocker:spyOn(jest.globalEnv.math, "random") + mockRand.mockReturnValueOnce("abcde") + + local returnValue = jest.globalEnv.math.random() + local callsAfter = #mockRand.mock.calls + + expect(callsAfter).toBe(0) + expect(returnValue).never.toBe("abcde") + expect(returnValue).toEqual(expect.any("number")) + + mockRand.mockReset() + end) + + it("globalEnv mocks do not persist beyond restoration", function() + local mockRand = moduleMocker:spyOn(jest.globalEnv.math, "random") + mockRand.mockReturnValueOnce("abcde") + mockRand.mockRestore() + + local returnValue = math.random() + + expect(returnValue).never.toBe("abcde") + expect(returnValue).toEqual(expect.any("number")) + end) + + it("globalEnv can still be mocked after restoration", function() + local mockRand = moduleMocker:spyOn(jest.globalEnv.math, "random") + mockRand.mockReturnValueOnce("abcde") + mockRand.mockRestore() + mockRand.mockReturnValueOnce("vwxyz") + + local returnValue = math.random() + + expect(returnValue).toBe("vwxyz") + end) +end) + +describe("oldFunctionSpying", function() + it("oldFunctionSpying = true injects mock objects", function() + local config = table.clone(JestConfig.projectDefaults) + config.oldFunctionSpying = true + local configuredMocker = ModuleMocker.new(config) + + local guineaPig = { + foo = function() end, + } + local mockObj = configuredMocker:spyOn(guineaPig, "foo") + + expect(mockObj).toEqual(expect.any("table")) + expect(guineaPig.foo).toEqual(expect.any("table")) + expect(guineaPig.foo).toEqual(mockObj) + end) + + it("oldFunctionSpying = false injects alike types", function() + local config = table.clone(JestConfig.projectDefaults) + config.oldFunctionSpying = false + local configuredMocker = ModuleMocker.new(config) + + local guineaPig = { + foo = function() end, + } + local mockObj = configuredMocker:spyOn(guineaPig, "foo") + + expect(mockObj).toEqual(expect.any("table")) + expect(guineaPig.foo).toEqual(expect.any("function")) + expect(guineaPig.foo).never.toEqual(mockObj) + end) + + it("defaults to backwards compatibile behaviour", function() + local guineaPig = { + foo = function() end, + } + local mockObj = moduleMocker:spyOn(guineaPig, "foo") + + expect(mockObj).toEqual(expect.any("table")) + expect(guineaPig.foo).toEqual(expect.any("table")) + expect(guineaPig.foo).toEqual(mockObj) + end) +end) + +describe("spyOn supported types", function() + it("allows functions", function() + local guineaPig = { + foo = function() end, + } + + expect(function() + moduleMocker:spyOn(guineaPig, "foo") + end).never.toThrow() + end) + + it("allows callable tables", function() + local guineaPig = { + foo = setmetatable({}, { __call = function() end }), + } + + expect(function() + moduleMocker:spyOn(guineaPig, "foo") + end).never.toThrow() + end) + + it("disallows non-callable tables", function() + local guineaPig = { + foo = {}, + bar = setmetatable({}, { __index = function() end }), + } + + expect(function() + moduleMocker:spyOn(guineaPig, "foo") + end).toThrow() + + expect(function() + moduleMocker:spyOn(guineaPig, "bar") + end).toThrow() + end) + + it("does not allow tables with locked callable metatables", function() + local guineaPig = { + foo = setmetatable({}, { __metatable = { __call = function() end } }), + } + + expect(function() + moduleMocker:spyOn(guineaPig, "foo") + end).toThrowErrorMatchingSnapshot() + end) + + it("disallows other kinds of value", function() + local guineaPig = { + foo = newproxy(true), + bar = 25, + } + + expect(function() + moduleMocker:spyOn(guineaPig, "foo") + end).toThrow() + + expect(function() + moduleMocker:spyOn(guineaPig, "bar") + end).toThrow() + end) +end) + +describe("callable table spying", function() + local guineaPig + local callable + local mock: exports.Mock + beforeEach(function() + callable = setmetatable({ + red = 1, + blue = 2, + }, { + florb = true, + __call = function(self, ...) + return { + 42 :: any, + { if self == callable then "original" else "mock", ... }, + } + end, + }) + guineaPig = { + foo = callable, + } + mock = moduleMocker:spyOn(guineaPig, "foo") + end) + + it("does not change default behaviour", function() + local result = guineaPig.foo("one", "two", "three") + expect(result[1]).toBe(42) + expect(result[2]).toEqual({ "mock", "one", "two", "three" }) + end) + it("supports the usual mocking facilities", function() + mock.mockReturnValue(25) + expect(guineaPig.foo()).toBe(25) + end) + it("passes correct parameters to mock implementation", function() + mock.mockImplementation(function(self: any, ...) + return { if self == callable then "original" else "mock", ... } + end) + expect(guineaPig.foo("foo", "bar", "baz")).toEqual({ "mock", "foo", "bar", "baz" }) + end) + it("uses a cloned metatable", function() + local original = getmetatable(callable) + local mock = getmetatable(guineaPig.foo) + expect(mock).never.toBe(original) + expect(mock.florb).toBe(true) + end) + it("uses a cloned table", function() + local original = callable + local mock = guineaPig.foo + expect(mock).never.toBe(original) + expect(mock.red).toBe(original.red) + end) +end) diff --git a/src/jest-mock/src/init.lua b/src/jest-mock/src/init.lua index eb1e359a..03354867 100644 --- a/src/jest-mock/src/init.lua +++ b/src/jest-mock/src/init.lua @@ -15,9 +15,23 @@ local LuauPolyfill = require("@pkg/@jsdotlua/luau-polyfill") local Array = LuauPolyfill.Array +local Boolean = LuauPolyfill.Boolean +local Error = LuauPolyfill.Error local Set = LuauPolyfill.Set local Symbol = LuauPolyfill.Symbol +-- ROBLOX deviation START: mocking globals +local JestMockGenv = require("@pkg/@jsdotlua/jest-mock-genv") +type GlobalMocker = JestMockGenv.GlobalMocker +type GlobalAutomocks = JestMockGenv.GlobalAutomocks +local GlobalMocker = JestMockGenv.GlobalMocker +-- ROBLOX deviation END + +-- ROBLOX deviation START: inject alike types +local JestTypes = require("@pkg/@jsdotlua/jest-types") +type Config_ProjectConfig = JestTypes.Config_ProjectConfig +-- ROBLOX deviation END + type Array = LuauPolyfill.Array type Object = LuauPolyfill.Object @@ -67,6 +81,8 @@ export type MaybeMocked = T original code lines 81 - 103 ]] +export type UnknownFunction = (...unknown) -> ...unknown +export type Mock = any -- ROBLOX TODO: Uncomment this type and use it once Luau has supported it -- ROBLOX TODO: Un in-line the MockInstance type declaration once we have "extends" syntax in Luau -- type Mock = { @@ -106,6 +122,9 @@ type MockFunctionConfig = { specificMockImpls: Array, } +-- ROBLOX deviation START: mocking globals +-- ROBLOX deviation END + export type ModuleMocker = { isMockFunction: (_self: ModuleMocker, fn: any) -> boolean, fn: (_self: ModuleMocker, implementation: ((Y...) -> T...)?) -> (MockFn, (...any) -> ...any), @@ -113,11 +132,28 @@ export type ModuleMocker = { resetAllMocks: (_self: ModuleMocker) -> (), restoreAllMocks: (_self: ModuleMocker) -> (), mocked: (_self: ModuleMocker, item: T, _deep: boolean?) -> MaybeMocked | MaybeMockedDeep, + spyOn: (_self: ModuleMocker, object: { [any]: any }, methodName: M, accessType: ("get" | "set")?) -> Mock, + -- ROBLOX deviation START: mocking globals + mockGlobals: (_self: ModuleMocker, globals: GlobalMocker, env: Object) -> (), + unmockGlobals: (_self: ModuleMocker, globals: GlobalMocker) -> (), + -- ROBLOX deviation END } ModuleMockerClass.__index = ModuleMockerClass -function ModuleMockerClass.new(): ModuleMocker +function ModuleMockerClass.new( + -- ROBLOX deviation: inject alike types + config: Config_ProjectConfig +): ModuleMocker local self = { + -- ROBLOX deviation START: inject alike types + _projectConfig = config, + _mocksOnObjectsMap = setmetatable({}, { + -- we have no use for knowledge about objects that user code has + -- discarded, no need to hold our info in memory strongly + -- we will have to revisit this for instance references + __mode = "k", + }), + -- ROBLOX deviation END _mockState = {}, _mockConfigRegistry = {}, _invocationCallCounter = 1, @@ -263,7 +299,7 @@ function ModuleMockerClass:_makeComponent(metadata: any, restore) end if typeof(restore) == "function" then - mocker._spyState.add(restore) + mocker._spyState:add(restore) end mocker._mockState[f] = mocker._defaultMockState() @@ -365,7 +401,12 @@ function ModuleMockerClass:_makeComponent(metadata: any, restore) f.mockImplementation(metadata.mockImpl) end - return f + -- ROBLOX deviation: fn is a callable table, return a forwarding function + return f, + function(...) + -- Should be identical to getmetatable(f).__call(f, ...) + return mockConstructor(f, ...) + end else error("Call to _makeComponent with non-function") end @@ -387,20 +428,178 @@ end -- ROBLOX TODO: type return type as JestMock.Mock when Mock type is implemented properly type MockFn = any -- (...any) -> ...any -function ModuleMockerClass:fn(implementation: ((Y...) -> T...)?): (MockFn, (...any) -> ...any) +function ModuleMockerClass:fn(implementation: ((Y...) -> T...)?): (MockFn, (T...) -> Y...) local length = 0 - local fn = self:_makeComponent({ length = length, type = "function" }) + -- ROBLOX deviation: fn is a callable table, return a forwarding function + local fn, mockFn = self:_makeComponent({ length = length, type = "function" }) if implementation then fn.mockImplementation(implementation) end + -- ROBLOX deviation: fn is a callable table, return a forwarding function + return fn, mockFn +end - -- ROBLOX deviation: fn is a callable table, - -- return a forwarding function as the second return value - local function mockFn(...) - return getmetatable(fn).__call(fn, ...) +function ModuleMockerClass:spyOn(object: { [any]: any }, methodName: M, accessType: ("get" | "set")?): Mock + if Boolean.toJSBoolean(accessType) then + return self:_spyOnProperty(object, methodName, accessType) + end + -- ROBLOX deviation: function types cannot have fields in lua + if typeof(object) ~= "table" then + error(Error.new(("Cannot spyOn on a primitive value; %s given"):format(typeof(object)))) end - return fn, mockFn + -- ROBLOX deviation START: inject alike types + local projectConfig = self._projectConfig :: Config_ProjectConfig + local mocksOnObject = self._mocksOnObjectsMap[object] + if mocksOnObject == nil then + mocksOnObject = {} + self._mocksOnObjectsMap[object] = mocksOnObject + end + -- ROBLOX deviation END + + -- ROBLOX deviation START: mocking globals + if GlobalMocker:isMockGlobalLibrary(object) then + local automocks = object._automocksRef + -- note: indexing non-mockable functions in `globalEnv` will error, + -- making this index operation subtly, but expectedly, fallible. + local automockFn = automocks[methodName] + if typeof(automockFn) ~= "table" or not automockFn._isGlobalAutomockFn then + error( + Error.new( + ("Cannot spy the %s property because it is not a function; %s given instead"):format( + tostring(methodName), + typeof(automockFn) + ) + ) + ) + elseif automockFn._maybeMock == nil then + error(Error.new("globalEnv has not been initialised by Jest here")) + end + return automockFn._maybeMock + end + -- ROBLOX deviation END + local original = object[methodName] + + -- ROBLOX deviation: inject alike types + if mocksOnObject[methodName] == nil then + -- ROBLOX deviation: multiple mock types supported, skip type check until later + + local isMethodOwner = rawget(object, methodName) ~= nil + -- ROBLOX deviation: ignore prototype and property descriptor logic + + -- ROBLOX deviation START: support multiple mock types with custom impl + local callableMetatable = nil + if typeof(original) == "table" then + local meta = getmetatable(original) + if typeof(meta) == "table" and meta.__call ~= nil then + callableMetatable = meta + end + end + + local mock, mockFn = self:_makeComponent({ type = "function" }, function() + object[methodName] = if isMethodOwner then original else nil + end) + + if typeof(original) == "function" then + object[methodName] = if projectConfig.oldFunctionSpying then mock else mockFn + mocksOnObject[methodName] = mock + mock.mockImplementation(function(...) + return original(...) + end) + elseif callableMetatable ~= nil then + local ok, mockTable = pcall(table.clone, original) + if not ok then + error( + Error.new( + ("Cannot spy the %s property because it cannot be cloned. (%s)"):format( + tostring(methodName), + mockTable:match("protected metatable") or mockTable + ) + ) + ) + end + local mockMetatable = table.clone(callableMetatable) + mockMetatable.__call = mockFn + -- It's unclear whether `original` should be deeply cloned here. See + -- the APT-1914 ticket on Jira for a discussion of this. + object[methodName] = setmetatable(mockTable, mockMetatable) + mocksOnObject[methodName] = mock + mock.mockImplementation(function(...) + return callableMetatable.__call(...) + end) + else + error( + Error.new( + ("Cannot spy the %s property because it is not a function or callable table; %s given instead"):format( + tostring(methodName), + typeof(original) + ) + ) + ) + end + -- ROBLOX deviation END + end + -- ROBLOX deviation: inject alike types + return mocksOnObject[methodName] +end +function ModuleMockerClass:_spyOnProperty(obj: T, propertyName: M, accessType_: ("get" | "set")?): Mock<() -> T> + -- ROBLOX deviation: spyOnProperty not supported + + -- ROBLOX note: A version of this behavior _could_ be implemented using some + -- elaborate metatable shenanigans, but we should find a compelling need + -- before pursuing that route + error("spyOn with accessors is not currently supported") + -- local accessType: "get" | "set" = if accessType_ ~= nil then accessType_ else "get" + -- if typeof(obj) ~= "table" and typeof(obj) ~= "function" then + -- error(Error.new(("Cannot spyOn on a primitive value; %s given"):format(tostring(self:_typeOf(obj))))) + -- end + -- if not Boolean.toJSBoolean(obj) then + -- error(Error.new(("spyOn could not find an object to spy upon for %s"):format(tostring(propertyName)))) + -- end + -- if not Boolean.toJSBoolean(propertyName) then + -- error(Error.new("No property name supplied")) + -- end + -- local descriptor = Object.getOwnPropertyDescriptor(obj, propertyName) + -- local proto = Object.getPrototypeOf(obj) + -- while not Boolean.toJSBoolean(descriptor) and proto ~= nil do + -- descriptor = Object.getOwnPropertyDescriptor(proto, propertyName) + -- proto = Object.getPrototypeOf(proto) + -- end + -- if not Boolean.toJSBoolean(descriptor) then + -- error(Error.new(("%s property does not exist"):format(tostring(propertyName)))) + -- end + -- if not Boolean.toJSBoolean(descriptor.configurable) then + -- error(Error.new(("%s is not declared configurable"):format(tostring(propertyName)))) + -- end + -- if not Boolean.toJSBoolean(descriptor[tostring(accessType)]) then + -- error( + -- Error.new(("Property %s does not have access type %s"):format(tostring(propertyName), tostring(accessType))) + -- ) + -- end + -- local original = descriptor[tostring(accessType)] + -- if not Boolean.toJSBoolean(self:isMockFunction(original)) then + -- if typeof(original) ~= "function" then + -- error( + -- Error.new( + -- ("Cannot spy the %s property because it is not a function; %s given instead"):format( + -- tostring(propertyName), + -- tostring(self:_typeOf(original)) + -- ) + -- ) + -- ) + -- end + -- descriptor[tostring(accessType)] = self:_makeComponent({ type = "function" }, function() + -- -- @ts-expect-error: mock is assignable + -- (descriptor :: any)[tostring(accessType)] = original + -- Object.defineProperty(obj, propertyName, descriptor :: any) + -- end); + -- (descriptor[tostring(accessType)] :: Mock<() -> T>):mockImplementation(function(this: unknown) + -- -- @ts-expect-error + -- return original(self, table.unpack(arguments)) + -- end) + -- end + -- Object.defineProperty(obj, propertyName, descriptor) + -- return descriptor[tostring(accessType)] :: Mock<() -> T> end function ModuleMockerClass:clearAllMocks() @@ -413,8 +612,8 @@ function ModuleMockerClass:resetAllMocks() end function ModuleMockerClass:restoreAllMocks() - for key, value in ipairs(self._spyState) do - key() + for _, value in self._spyState do + value() end self._spyState = Set.new() end @@ -431,17 +630,69 @@ function ModuleMockerClass:mocked(item: T, _deep: boolean?): MaybeMocked | return item :: any end -exports.ModuleMocker = ModuleMockerClass +-- ROBLOX deviation START: mocking globals +function ModuleMockerClass:mockGlobals(globalMocker: GlobalMocker, env: Object) + assert(not globalMocker.currentlyMocked, "Attempt to mock globals while they're already mocked") + globalMocker.currentlyMocked = true + local function implement(automocks: GlobalAutomocks, env: Object) + for name, automock in automocks do + if automock._isGlobalAutomockFn then + local original = env[name] + local mock + local function mockOriginalImplementation() + mock.mockImplementation(function(...) + return original(...) + end) + end + mock = self:_makeComponent({ + type = "function", + }, mockOriginalImplementation) + mockOriginalImplementation() + automock._maybeUnmocked = original + automock._maybeMock = mock + else + implement(automock, env[name]) + end + end + end + implement(globalMocker.automocks, env) +end -local JestMock = ModuleMockerClass.new() -local fn = function(implementation: ((Y...) -> T...)?) - return JestMock:fn(implementation) +function ModuleMockerClass:unmockGlobals(globalMocker: GlobalMocker) + globalMocker.currentlyMocked = false + local function unimplement(automocks: GlobalAutomocks) + for name, automock in automocks do + if automock._isGlobalAutomockFn then + automock._maybeUnmocked = nil + automock._maybeMock = nil + else + unimplement(automock) + end + end + end + unimplement(globalMocker.automocks) end -exports.fn = fn --- ROBLOX TODO: spyOn is not implemented --- local spyOn = JestMock.spyOn +-- ROBLOX deviation END + +exports.ModuleMocker = ModuleMockerClass + +-- ROBLOX deviation START: can't provide this globally because it needs a config +-- local JestMock = ModuleMockerClass.new() +-- local fn = function(implementation: ((Y...) -> T...)?) +-- return JestMock:fn(implementation) +-- end +-- exports.fn = fn +-- local spyOn = function(object: { [any]: any }, methodName: M, accessType: ("get" | "set")?): Mock +-- return JestMock:spyOn(object, methodName, accessType) +-- end -- exports.spyOn = spyOn -local mocked = JestMock.mocked -exports.mocked = mocked +-- local mocked = function(item: T, _deep: boolean?): MaybeMocked | MaybeMockedDeep +-- return JestMock:mocked(item, _deep) +-- end +-- exports.mocked = mocked +export type JestFuncFn = (implementation: ((Y...) -> T...)?) -> (MockFn, (T...) -> Y...) +export type JestFuncMocked = (object: { [any]: any }, methodName: M, accessType: ("get" | "set")?) -> Mock +export type JestFuncSpyOn = (item: T, _deep: boolean?) -> MaybeMocked | MaybeMockedDeep +-- ROBLOX deviation END return exports diff --git a/src/jest-reporters/README.md b/src/jest-reporters/README.md index 23429986..09b06859 100644 --- a/src/jest-reporters/README.md +++ b/src/jest-reporters/README.md @@ -1,19 +1,7 @@ # jest-reporters -Status: :heavy_check_mark: Ported - -Source: https://github.com/facebook/jest/tree/v27.4.7/packages/jest-reporters - -Version: v27.4.7 +Upstream: https://github.com/facebook/jest/tree/v27.4.7/packages/jest-reporters --- ### :pencil2: Notes - -### :x: Excluded -``` -``` - -### :package: [Dependencies](https://github.com/facebook/jest/blob/v27.4.7/packages/jest-reporters/package.json) -| Package | Version | Status | Notes | -| ------------------ | ------- | ------------------------- | ------------------------------------------------ | diff --git a/src/jest-reporters/package.json b/src/jest-reporters/package.json index 17d5b823..840190ed 100644 --- a/src/jest-reporters/package.json +++ b/src/jest-reporters/package.json @@ -24,6 +24,7 @@ "@jsdotlua/path": "workspace:^" }, "devDependencies": { + "@jsdotlua/jest-config": "workspace:^", "@jsdotlua/jest-globals": "workspace:^", "@jsdotlua/jest-snapshot-serializer-raw": "workspace:^", "@jsdotlua/pretty-format": "workspace:^", diff --git a/src/jest-reporters/src/__tests__/DefaultReporter.spec.lua b/src/jest-reporters/src/__tests__/DefaultReporter.spec.lua index d534d8c4..abb0b9be 100644 --- a/src/jest-reporters/src/__tests__/DefaultReporter.spec.lua +++ b/src/jest-reporters/src/__tests__/DefaultReporter.spec.lua @@ -12,8 +12,11 @@ local expect = JestGlobals.expect local it = JestGlobals.it local beforeEach = JestGlobals.beforeEach +-- ROBLOX deviation: pass config to module mocker +local JestConfig = require("@pkg/@jsdotlua/jest-config") local ModuleMocker = require("@pkg/@jsdotlua/jest-mock").ModuleMocker -local moduleMocker = ModuleMocker.new() +-- ROBLOX deviation: pass config to module mocker +local moduleMocker = ModuleMocker.new(JestConfig.projectDefaults) local DefaultReporter = require("../DefaultReporter").default local Writeable = require("@pkg/@jsdotlua/jest-roblox-shared").Writeable diff --git a/src/jest-roblox-shared/README.md b/src/jest-roblox-shared/README.md index 014a15f7..61ea0899 100644 --- a/src/jest-roblox-shared/README.md +++ b/src/jest-roblox-shared/README.md @@ -1,20 +1,7 @@ # roblox-shared -Status: :hammer: In Progress - -Source: N/A - -Version: +This module primarily contains shared util code moved out from the internals of other Jest modules to avoid cycles. --- ### :pencil2: Notes -* This module contains shared util code moved out from the internals of other modules to avoid cycles. - -### :x: Excluded -``` -``` - -### :package: [Dependencies]() -| Package | Version | Status | Notes | -| ------- | ------- | ------ | ----- | diff --git a/src/jest-roblox-shared/package.json b/src/jest-roblox-shared/package.json index ac65ce67..6e82a1ae 100644 --- a/src/jest-roblox-shared/package.json +++ b/src/jest-roblox-shared/package.json @@ -17,6 +17,7 @@ "@jsdotlua/luau-polyfill": "^1.2.6" }, "devDependencies": { + "@jsdotlua/jest-config": "workspace:^", "@jsdotlua/jest-globals": "workspace:^", "npmluau": "^0.1.1" } diff --git a/src/jest-roblox-shared/src/RobloxInstance.lua b/src/jest-roblox-shared/src/RobloxInstance.lua index a2f45b89..1db68c19 100644 --- a/src/jest-roblox-shared/src/RobloxInstance.lua +++ b/src/jest-roblox-shared/src/RobloxInstance.lua @@ -27,55 +27,87 @@ local isObjectWithKeys = CurrentModuleExpect.isObjectWithKeys local hasPropertyInObject = CurrentModuleExpect.hasPropertyInObject local isAsymmetric = CurrentModuleExpect.isAsymmetric -local _cachedPropertyValues = {} +local exports = {} -local function tryPropertyName(instance, propertyName) +-- Unsafe because no checks are performed that this property is readable. +local function readPropUnsafe(instance: Instance, propertyName: string): unknown return instance[propertyName] end -local function getRobloxProperties(class: string): { string } - local instanceClass = RobloxApi[class] - local t = {} - while instanceClass do - for _, property in ipairs(instanceClass.Properties) do - table.insert(t, property) - end - instanceClass = RobloxApi[instanceClass.Superclass] - end - table.sort(t) - return t +-- Unsafe because no checks are performed that this property is writable. +local function writePropUnsafe(instance: Instance, propertyName: string, value: unknown): () + instance[propertyName] = value end -local function getRobloxDefaults(className: string): ({ [string]: any }, { string }) - local propertiesList = getRobloxProperties(className) - - local classCache = _cachedPropertyValues[className] - if classCache then - return classCache, propertiesList - else - classCache = {} - _cachedPropertyValues[className] = classCache - end +function exports.readProp(instance: Instance, propertyName: string) + return pcall(readPropUnsafe, instance, propertyName) +end - local created = Instance.new(className) +function exports.writeProp(instance: Instance, propertyName: string, value: unknown) + return pcall(writePropUnsafe, instance, propertyName, value) +end - for _, propertyName in ipairs(propertiesList) do - local ok, defaultValue = pcall(tryPropertyName, created, propertyName) +-- Unsafe because no checks are performed that these properties are readable. +local function listPropsUnsafe(className: string): { [string]: true } + local unsafeProps = {} + local inheritFrom = RobloxApi[className] + while inheritFrom ~= nil do + for _, unsafeProp in ipairs(inheritFrom.Properties) do + unsafeProps[unsafeProp] = true + end + inheritFrom = RobloxApi[inheritFrom.Superclass] + end + return unsafeProps +end +function exports.listProps(instance: Instance, warmRead: boolean?): { [string]: unknown } + local props = listPropsUnsafe(instance.ClassName) + -- cold read - values may not be stable, but at least we can weed out + -- property reads that will result in errors + for unsafeProp in props do + local ok, propValue = exports.readProp(instance, unsafeProp) if ok then - classCache[propertyName] = defaultValue + props[unsafeProp] = if propValue == nil then Object.None else propValue + else + props[unsafeProp] = nil end end + if warmRead then + -- warm read - quantum UI bugs will no longer affect values here + for safeProp in props do + local propValue = readPropUnsafe(instance, safeProp) + props[safeProp] = if propValue == nil then Object.None else propValue + end + end + return props +end - created:Destroy() - return classCache, propertiesList +do + -- Hidden from outside code. + local cachedDefaults = {} + function exports.listDefaultProps(className: string): { [string]: unknown } + local cached = cachedDefaults[className] + if cached ~= nil then + return cached + end + + local ok, instance = pcall(Instance.new, className) + if not ok then + error("Class type is abstract or not creatable - cannot list defaults") + end + local defaults = exports.listProps(instance) + instance:Destroy() + + cachedDefaults[className] = defaults + return defaults + end end -- given an Instance and a property-value table subset -- returns true if all property-values in the subset table exist in the Instance -- and returns false otherwise -- returns nil for undefined behavior -local function instanceSubsetEquality(instance: any, subset: any): boolean | nil +function exports.instanceSubsetEquality(instance: any, subset: any): boolean | nil local function subsetEqualityWithContext(seenReferences) return function(localInstance, localSubset) seenReferences = seenReferences or {} @@ -84,12 +116,6 @@ local function instanceSubsetEquality(instance: any, subset: any): boolean | nil return nil end - local instanceProperties = getRobloxProperties(localInstance.ClassName) - local instanceChildren = {} - for _, v in ipairs(localInstance:getChildren()) do - instanceChildren[v.Name] = true - end - return Array.every(Object.keys(localSubset), function(prop) local subsetVal = localSubset[prop] if isObjectWithKeys(subsetVal) then @@ -99,9 +125,8 @@ local function instanceSubsetEquality(instance: any, subset: any): boolean | nil end seenReferences[subsetVal] = true end - local result = localInstance ~= nil - and (table.find(instanceProperties, prop) ~= nil or instanceChildren[prop] ~= nil) - and equals(localInstance[prop], subsetVal, { subsetEqualityWithContext(seenReferences) }) + local ok, value = exports.readProp(localInstance, prop) + local result = ok and equals(value, subsetVal, { subsetEqualityWithContext(seenReferences) }) seenReferences[subsetVal] = nil return result @@ -118,8 +143,12 @@ local function instanceSubsetEquality(instance: any, subset: any): boolean | nil end -- InstanceSubset object behaves like an Instance when serialized by pretty-format + local InstanceSubset = {} +exports.InstanceSubset = InstanceSubset + InstanceSubset.__index = InstanceSubset + function InstanceSubset.new(className, subset) table.sort(subset) local self = { @@ -131,14 +160,14 @@ function InstanceSubset.new(className, subset) return self end --- given an Instance and a property-value table subset, returns --- an InstanceSubset object representing the subset of Instance with values in the subset table --- and a InstanceSubset object representing the subset table -local function getInstanceSubset(instance: any, subset: any, seenReferences_: any?): (any, any) +-- given an Instance and an expected property-value table subset, returns +-- an InstanceSubset object representing the found subset of Instance with values in the subset table +-- and a InstanceSubset object representing the expected subset table +function exports.getInstanceSubset(instance: any, subset: any, seenReferences_: any?): (any, any) local seenReferences = seenReferences_ or {} - local trimmed: any = {} - seenReferences[instance] = trimmed + local foundSubset: any = {} + seenReferences[instance] = foundSubset -- return non-table primitives if equals(instance, subset) then @@ -148,29 +177,23 @@ local function getInstanceSubset(instance: any, subset: any, seenReferences_: an end -- collect non-table primitive values - local newSubset = {} + local expectedSubset = {} for k, v in pairs(subset) do if typeof(v) ~= "table" then - newSubset[k] = v + expectedSubset[k] = v end end - local propsAndChildren = getRobloxProperties(instance.ClassName) - for _, v in ipairs(instance:getChildren()) do - table.insert(propsAndChildren, v.Name) - end - - for i, prop in - ipairs(Array.filter(propsAndChildren, function(prop) - return hasPropertyInObject(subset, prop) - end)) - do - if seenReferences[instance[prop]] ~= nil then + for name, subsetPropOrChild in pairs(subset) do + local ok, realPropOrChild = exports.readProp(instance, name) + if not ok then + continue + elseif seenReferences[realPropOrChild] ~= nil then error("Circular reference passed into .toMatchInstance(subset)") else - local nestedSubset - trimmed[prop], nestedSubset = getInstanceSubset(instance[prop], subset[prop], seenReferences) - newSubset[prop] = nestedSubset + expectedSubset[name] = {} + foundSubset[name], expectedSubset[name] = + exports.getInstanceSubset(realPropOrChild, subsetPropOrChild, seenReferences) end end @@ -181,13 +204,9 @@ local function getInstanceSubset(instance: any, subset: any, seenReferences_: an subsetClassName = rawget(subset, "ClassName") end - return InstanceSubset.new(instance.ClassName, trimmed), InstanceSubset.new(subsetClassName, newSubset) + local found = InstanceSubset.new(instance.ClassName, foundSubset) + local expected = InstanceSubset.new(subsetClassName, expectedSubset) + return found, expected end -return { - getRobloxProperties = getRobloxProperties, - getRobloxDefaults = getRobloxDefaults, - instanceSubsetEquality = instanceSubsetEquality, - InstanceSubset = InstanceSubset, - getInstanceSubset = getInstanceSubset, -} +return exports diff --git a/src/jest-roblox-shared/src/__tests__/RobloxInstance.roblox.spec.lua b/src/jest-roblox-shared/src/__tests__/RobloxInstance.roblox.spec.lua index e90c90b5..e01c4261 100644 --- a/src/jest-roblox-shared/src/__tests__/RobloxInstance.roblox.spec.lua +++ b/src/jest-roblox-shared/src/__tests__/RobloxInstance.roblox.spec.lua @@ -12,6 +12,9 @@ * See the License for the specific language governing permissions and * limitations under the License. ]] +local LuauPolyfill = require("@pkg/@jsdotlua/luau-polyfill") +local Object = LuauPolyfill.Object + local JestGlobals = require("@pkg/@jsdotlua/jest-globals") local expect = JestGlobals.expect local describe = JestGlobals.describe @@ -20,73 +23,89 @@ local it = JestGlobals.it local RobloxInstance = require("../RobloxInstance") local instanceSubsetEquality = RobloxInstance.instanceSubsetEquality local getInstanceSubset = RobloxInstance.getInstanceSubset -local getRobloxProperties = RobloxInstance.getRobloxProperties -local getRobloxDefaults = RobloxInstance.getRobloxDefaults +local listProps = RobloxInstance.listProps +local listDefaultProps = RobloxInstance.listDefaultProps local InstanceSubset = RobloxInstance.InstanceSubset -describe("getRobloxProperties()", function() - it("returns properties for Instance", function() - expect(getRobloxProperties("Instance")).toEqual({ "Archivable", "ClassName", "Name", "Parent" }) +describe("listDefaultProps()", function() + it("doesn't return properties for abstract superclasses", function() + expect(function() + listDefaultProps("Instance") + end).toThrow("abstract or not creatable") end) it("doesn't return protected properties", function() - expect(getRobloxProperties("ModuleScript")).never.toContain({ "Source" }) + expect(listDefaultProps("ModuleScript")).never.toHaveProperty("Source") end) it("doesn't return hidden properties", function() - expect(getRobloxProperties("TextLabel")).never.toContain({ "LocalizedText", "Transparency" }) - end) - - it("returns all properties and inherited properties of Frame", function() - expect(getRobloxProperties("Frame")).toEqual({ - "AbsolutePosition", - "AbsoluteRotation", - "AbsoluteSize", - "Active", - "AnchorPoint", - "Archivable", - "AutoLocalize", - "AutomaticSize", - "BackgroundColor3", - "BackgroundTransparency", - "BorderColor3", - "BorderMode", - "BorderSizePixel", - "ClassName", - "ClipsDescendants", - "LayoutOrder", - "Name", - "NextSelectionDown", - "NextSelectionLeft", - "NextSelectionRight", - "NextSelectionUp", - "Parent", - "Position", - "RootLocalizationTable", - "Rotation", - "Selectable", - "SelectionImageObject", - "Size", - "SizeConstraint", - "Style", - "Visible", - "ZIndex", - }) + expect(listDefaultProps("TextLabel")).never.toHaveProperty("LocalizedText") + expect(listDefaultProps("TextLabel")).never.toHaveProperty("Transparency") + end) + + it("returns inherited properties", function() + expect(listDefaultProps("Part")).toHaveProperty("Anchored") + end) + + it("returns nil properties as None", function() + expect(listDefaultProps("Part")).toHaveProperty("Parent", Object.None) end) -end) -describe("getRobloxDefaults()", function() it("returns default properties and values for TextLabel", function() - local defaults = getRobloxDefaults("TextLabel") + local defaults = listDefaultProps("TextLabel") expect(defaults).toMatchSnapshot() end) it("returns default properties and values for Camera", function() - local defaults = getRobloxDefaults("Camera") - expect(defaults).toMatchSnapshot({ - DiagonalFieldOfView = expect.closeTo(88.87651, 3), + local defaults = listDefaultProps("Camera") + expect(defaults).toMatchSnapshot() + end) +end) + +describe("listProps()", function() + it("returns properties for a simple instance", function() + local simpleInstance = Instance.new("ObjectValue") + simpleInstance.Name = "Bryan" + simpleInstance.Value = simpleInstance + expect(listProps(simpleInstance)).toEqual({ + Archivable = true, + ClassName = "ObjectValue", + Name = "Bryan", + Parent = Object.None, + Value = simpleInstance, }) end) + + it("doesn't return protected properties", function() + local moduleScript = Instance.new("ModuleScript") + expect(listProps(moduleScript)).never.toHaveProperty("Source") + end) + + it("doesn't return hidden properties", function() + local textLabel = Instance.new("TextLabel") + expect(listProps(textLabel)).never.toHaveProperty("LocalizedText") + expect(listProps(textLabel)).never.toHaveProperty("Transparency") + end) + + it("returns inherited properties", function() + local part = Instance.new("Part") + expect(listProps(part)).toHaveProperty("Anchored") + end) + + it("returns nil properties as None", function() + local part = Instance.new("Part") + expect(listProps(part)).toHaveProperty("Parent", Object.None) + end) + + it("returns properties and values for TextLabel", function() + local props = listProps(Instance.new("TextLabel")) + expect(props).toMatchSnapshot() + end) + + it("returns properties and values for Camera", function() + local props = listProps(Instance.new("Camera")) + expect(props).toMatchSnapshot() + end) end) describe("instanceSubsetEquality()", function() diff --git a/src/jest-roblox-shared/src/__tests__/Writeable.spec.lua b/src/jest-roblox-shared/src/__tests__/Writeable.spec.lua index 9b5a0cc7..5a93baf9 100644 --- a/src/jest-roblox-shared/src/__tests__/Writeable.spec.lua +++ b/src/jest-roblox-shared/src/__tests__/Writeable.spec.lua @@ -16,13 +16,15 @@ local Writeable = require("../Writeable").Writeable +local JestConfig = require("@pkg/@jsdotlua/jest-config") + local ModuleMocker = require("@pkg/@jsdotlua/jest-mock").ModuleMocker local JestGlobals = require("@pkg/@jsdotlua/jest-globals") local expect = JestGlobals.expect local describe = JestGlobals.describe local it = JestGlobals.it -local moduleMocker = ModuleMocker.new() +local moduleMocker = ModuleMocker.new(JestConfig.projectDefaults) local mockWrite = moduleMocker:fn() describe("Writeable", function() diff --git a/src/jest-roblox-shared/src/__tests__/__snapshots__/RobloxInstance.roblox.spec.snap.lua b/src/jest-roblox-shared/src/__tests__/__snapshots__/RobloxInstance.roblox.spec.snap.lua index 9ee504f1..b2366469 100644 --- a/src/jest-roblox-shared/src/__tests__/__snapshots__/RobloxInstance.roblox.spec.snap.lua +++ b/src/jest-roblox-shared/src/__tests__/__snapshots__/RobloxInstance.roblox.spec.snap.lua @@ -1,10 +1,11 @@ -- Jest Roblox Snapshot v1, http://roblox.github.io/jest-roblox-internal/snapshot-testing local exports = {} -exports[ [=[getRobloxDefaults() returns default properties and values for Camera 1]=] ] = [=[ +exports[ [=[listDefaultProps() returns default properties and values for Camera 1]=] ] = [=[ Table { "Archivable": true, "CFrame": CFrame(0, 20, 20, 1, 0, -0, -0, 0.707106829, 0.707106829, 0, -0.707106829, 0.707106829), + "CameraSubject": Object.None, "CameraType": EnumItem(Enum.CameraType.Fixed), "ClassName": "Camera", "DiagonalFieldOfView": NumberCloseTo 88.87651 (3 digits), @@ -16,11 +17,12 @@ Table { "MaxAxisFieldOfView": 70, "Name": "Camera", "NearPlaneZ": -0.5, + "Parent": Object.None, "ViewportSize": Vector2(1, 1), } ]=] -exports[ [=[getRobloxDefaults() returns default properties and values for TextLabel 1]=] ] = [=[ +exports[ [=[listDefaultProps() returns default properties and values for TextLabel 1]=] ] = [=[ Table { "AbsolutePosition": Vector2(0, 0), @@ -44,10 +46,94 @@ Table { "LineHeight": 1, "MaxVisibleGraphemes": -1, "Name": "TextLabel", + "NextSelectionDown": Object.None, + "NextSelectionLeft": Object.None, + "NextSelectionRight": Object.None, + "NextSelectionUp": Object.None, + "Parent": Object.None, "Position": UDim2({0, 0}, {0, 0}), "RichText": false, + "RootLocalizationTable": Object.None, "Rotation": 0, "Selectable": false, + "SelectionImageObject": Object.None, + "Size": UDim2({0, 0}, {0, 0}), + "SizeConstraint": EnumItem(Enum.SizeConstraint.RelativeXY), + "Text": "Label", + "TextBounds": Vector2(0, 0), + "TextColor3": Color3(0.105882, 0.164706, 0.207843), + "TextFits": false, + "TextScaled": false, + "TextSize": 8, + "TextStrokeColor3": Color3(0, 0, 0), + "TextStrokeTransparency": 1, + "TextTransparency": 0, + "TextTruncate": EnumItem(Enum.TextTruncate.None), + "TextWrapped": false, + "TextXAlignment": EnumItem(Enum.TextXAlignment.Center), + "TextYAlignment": EnumItem(Enum.TextYAlignment.Center), + "Visible": true, + "ZIndex": 1, +} +]=] + +exports[ [=[listProps() returns properties and values for Camera 1]=] ] = [=[ + +Table { + "Archivable": true, + "CFrame": CFrame(0, 20, 20, 1, 0, -0, -0, 0.707106829, 0.707106829, 0, -0.707106829, 0.707106829), + "CameraSubject": Object.None, + "CameraType": EnumItem(Enum.CameraType.Fixed), + "ClassName": "Camera", + "DiagonalFieldOfView": 88.87651062011719, + "FieldOfView": 70, + "FieldOfViewMode": EnumItem(Enum.FieldOfViewMode.Vertical), + "Focus": CFrame(0, 0, -5, 1, 0, 0, 0, 1, 0, 0, 0, 1), + "HeadLocked": true, + "HeadScale": 1, + "MaxAxisFieldOfView": 70, + "Name": "Camera", + "NearPlaneZ": -0.5, + "Parent": Object.None, + "ViewportSize": Vector2(1, 1), +} +]=] + +exports[ [=[listProps() returns properties and values for TextLabel 1]=] ] = [=[ + +Table { + "AbsolutePosition": Vector2(0, 0), + "AbsoluteRotation": 0, + "AbsoluteSize": Vector2(0, 0), + "Active": false, + "AnchorPoint": Vector2(0, 0), + "Archivable": true, + "AutoLocalize": true, + "AutomaticSize": EnumItem(Enum.AutomaticSize.None), + "BackgroundColor3": Color3(0.639216, 0.635294, 0.647059), + "BackgroundTransparency": 0, + "BorderColor3": Color3(0.105882, 0.164706, 0.207843), + "BorderMode": EnumItem(Enum.BorderMode.Outline), + "BorderSizePixel": 1, + "ClassName": "TextLabel", + "ClipsDescendants": false, + "ContentText": "Label", + "Font": EnumItem(Enum.Font.Legacy), + "LayoutOrder": 0, + "LineHeight": 1, + "MaxVisibleGraphemes": -1, + "Name": "TextLabel", + "NextSelectionDown": Object.None, + "NextSelectionLeft": Object.None, + "NextSelectionRight": Object.None, + "NextSelectionUp": Object.None, + "Parent": Object.None, + "Position": UDim2({0, 0}, {0, 0}), + "RichText": false, + "RootLocalizationTable": Object.None, + "Rotation": 0, + "Selectable": false, + "SelectionImageObject": Object.None, "Size": UDim2({0, 0}, {0, 0}), "SizeConstraint": EnumItem(Enum.SizeConstraint.RelativeXY), "Text": "Label", diff --git a/src/jest-roblox-shared/src/__tests__/__snapshots__/redactStackTrace.spec.snap.lua b/src/jest-roblox-shared/src/__tests__/__snapshots__/redactStackTrace.spec.snap.lua new file mode 100644 index 00000000..8214288e --- /dev/null +++ b/src/jest-roblox-shared/src/__tests__/__snapshots__/redactStackTrace.spec.snap.lua @@ -0,0 +1,19 @@ +-- Jest Roblox Snapshot v1, http://roblox.github.io/jest-roblox-internal/snapshot-testing +local exports = {} +exports[ [=[stack traces should stay the same in snapshots 1]=] ] = [=[ + +"Redacted.Stack.Trace:1337 function epicDuck +Redacted.Stack.Trace:1337 function epicDuck +Redacted.Stack.Trace:1337 function epicDuck +Redacted.Stack.Trace:1337 function epicDuck" +]=] + +exports[ [=[stack traces should stay the same in snapshots 2]=] ] = [=[ + +"Redacted.Stack.Trace:1337 function epicDuck +Redacted.Stack.Trace:1337 function epicDuck +Redacted.Stack.Trace:1337 function epicDuck +Redacted.Stack.Trace:1337 function epicDuck" +]=] + +return exports diff --git a/src/jest-snapshot/src/__tests__/utils.roblox.spec.lua b/src/jest-roblox-shared/src/__tests__/getParent.test.lua similarity index 97% rename from src/jest-snapshot/src/__tests__/utils.roblox.spec.lua rename to src/jest-roblox-shared/src/__tests__/getParent.test.lua index 5dbd2026..097d5865 100644 --- a/src/jest-snapshot/src/__tests__/utils.roblox.spec.lua +++ b/src/jest-roblox-shared/src/__tests__/getParent.test.lua @@ -19,7 +19,7 @@ local expect = JestGlobals.expect local describe = JestGlobals.describe local it = JestGlobals.it -local getParent = require("../utils").robloxGetParent +local getParent = require("../getParent") describe("getParent", function() it("works on Unix paths", function() diff --git a/src/jest-roblox-shared/src/__tests__/redactStackTrace.spec.lua b/src/jest-roblox-shared/src/__tests__/redactStackTrace.spec.lua new file mode 100644 index 00000000..8213bb34 --- /dev/null +++ b/src/jest-roblox-shared/src/__tests__/redactStackTrace.spec.lua @@ -0,0 +1,81 @@ +--[[ + * Copyright (c) Roblox Corporation. All rights reserved. + * Licensed under the MIT License (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://opensource.org/licenses/MIT + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. +]] +-- ROBLOX note: no upstream + +local redactStackTrace = require("../redactStackTrace") + +local JestGlobals = require("@pkg/@jsdotlua/jest-globals") +local expect = JestGlobals.expect +local it = JestGlobals.it + +local REAL_STACK_TRACE = "LoadedCode.JestRoblox._Workspace.JestCircus.JestCircus.circus.__tests__.errorParsing.roblox.spec:51\n" + .. "LoadedCode.JestRoblox._Workspace.JestRuntime.JestRuntime:2046 function _execModule\n" + .. "LoadedCode.JestRoblox._Workspace.JestRuntime.JestRuntime:1414 function _loadModule\n" + .. "LoadedCode.JestRoblox._Workspace.JestRuntime.JestRuntime:1260\n" + .. "LoadedCode.JestRoblox._Workspace.JestRuntime.JestRuntime:1259 function requireModule\n" + .. "LoadedCode.JestRoblox._Workspace.JestCircus.JestCircus.circus.legacy-code-todo-rewrite.jestAdapter:114" + +local REAL_STACK_TRACE_ALT = "LoadedCode.JestRoblox._Workspace.JestCircus.JestCircus.circus.utils:555 function _getError\n" + .. "LoadedCode.JestRoblox._Workspace.JestCircus.JestCircus.circus.utils:443 function makeRunResult\n" + .. "LoadedCode.JestRoblox._Workspace.JestCircus.JestCircus.circus.__tests__.errorParsing.roblox.spec:56\n" + .. "LoadedCode.JestRoblox._Workspace.JestEach.JestEach.bind:170\n" + .. "LoadedCode.JestRoblox._Workspace.JestCircus.JestCircus.circus.utils:369" + +local LUA_STYLE_ERROR = + "LoadedCode.JestRoblox._Workspace.JestCircus.JestCircus.circus.utils:555: Something bad happened!" + +it("should handle nil", function() + expect(function() + expect(redactStackTrace(nil)).toBeNil() + end).never.toThrow() +end) + +it("should redact a stack trace on its own", function() + expect(redactStackTrace(REAL_STACK_TRACE)).never.toEqual(REAL_STACK_TRACE) +end) + +it("should redact a stack trace embedded in a message", function() + local stack = "I am a message " .. REAL_STACK_TRACE .. "\nI am a message" + expect(redactStackTrace(stack)).never.toEqual(stack) +end) + +it("should leave non-stack-frame lines unchanged", function() + local stack = "This is the front " .. REAL_STACK_TRACE .. "\nThis is the back" + + local redacted = redactStackTrace(stack) + expect(redacted).toContain("This is the front") + expect(redacted).toContain("This is the back") +end) + +it("should not vary length or content between stack traces", function() + expect(redactStackTrace(REAL_STACK_TRACE)).toEqual(redactStackTrace(REAL_STACK_TRACE_ALT)) +end) + +it("stack traces should stay the same in snapshots", function() + expect(redactStackTrace(REAL_STACK_TRACE)).toMatchSnapshot() + expect(redactStackTrace(REAL_STACK_TRACE_ALT)).toMatchSnapshot() +end) + +it("does not add trailing newlines", function() + expect(redactStackTrace(REAL_STACK_TRACE)).never.toMatch("\n$") +end) + +it("preserves trailing newlines", function() + expect(redactStackTrace(REAL_STACK_TRACE) :: any .. "\n").toMatch("\n$") +end) + +it("supports Lua-style errors", function() + expect(redactStackTrace(LUA_STYLE_ERROR)).never.toEqual(LUA_STYLE_ERROR) +end) diff --git a/src/jest-roblox-shared/src/cleanLoadStringStack.lua b/src/jest-roblox-shared/src/cleanLoadStringStack.lua new file mode 100644 index 00000000..9855c737 --- /dev/null +++ b/src/jest-roblox-shared/src/cleanLoadStringStack.lua @@ -0,0 +1,21 @@ +local loadModuleEnabled = pcall((debug :: any).loadmodule, Instance.new("ModuleScript")) + +return function(line: string): string + if not loadModuleEnabled then + local spacing, filePath, lineNumber, extra = line:match('(%s*)%[string "(.-)"%]:(%d+)(.*)') + if filePath then + local match = filePath + if spacing then + match = spacing .. match + end + if lineNumber then + match = match .. ":" .. lineNumber + end + if extra then + match = match .. extra + end + return match + end + end + return line +end diff --git a/src/jest-roblox-shared/src/ensureDirectoryExists.lua b/src/jest-roblox-shared/src/ensureDirectoryExists.lua new file mode 100644 index 00000000..dd004b83 --- /dev/null +++ b/src/jest-roblox-shared/src/ensureDirectoryExists.lua @@ -0,0 +1,30 @@ +-- moved from jest-snapshot/utils.lua + +local LuauPolyfill = require("@pkg/@jsdotlua/luau-polyfill") +local Error = LuauPolyfill.Error + +local getParent = require("./getParent") +local getDataModelService = require("./getDataModelService") + +local FileSystemService = getDataModelService("FileSystemService") + +local function ensureDirectoryExists(filePath: string) + -- ROBLOX deviation: gets path of parent directory, GetScriptFilePath can only be called on ModuleScripts + local path = getParent(filePath, 1) + local ok, err = pcall(function() + if FileSystemService and not FileSystemService:Exists(path) then + FileSystemService:CreateDirectories(path) + end + end) + + if not ok and err:find("Error%(13%): Access Denied%. Path is outside of sandbox%.") then + error( + Error.new( + "Provided path is invalid: you likely need to provide a different argument to --fs.readwrite.\n" + .. "You may need to pass in `--fs.readwrite=$PWD`" + ) + ) + end +end + +return ensureDirectoryExists diff --git a/src/jest-roblox-shared/src/getDataModelService.lua b/src/jest-roblox-shared/src/getDataModelService.lua new file mode 100644 index 00000000..ae0906e6 --- /dev/null +++ b/src/jest-roblox-shared/src/getDataModelService.lua @@ -0,0 +1,10 @@ +-- checks that the service exists and is accessible before returning it, otherwise returns nil +return function(service: string) + local success, result = pcall(function() + local service = game:GetService(service) + local _ = service.Name + return service + end) + + return success and result or nil +end diff --git a/src/jest-roblox-shared/src/getParent.lua b/src/jest-roblox-shared/src/getParent.lua new file mode 100644 index 00000000..4381785b --- /dev/null +++ b/src/jest-roblox-shared/src/getParent.lua @@ -0,0 +1,22 @@ +-- ROBLOX deviation: added to handle file paths in snapshot/State +local function getParent(path: string, level_: number?): string + local level = if level_ then level_ else 0 + + local isUnixPath = string.sub(path, 1, 1) == "/" + local t = {} + + for p in string.gmatch(path, "[^\\/][^\\/]*") do + table.insert(t, p) + end + if level > 0 then + t = { table.unpack(t, 1, #t - level) } + end + + if isUnixPath then + return "/" .. table.concat(t, "/") + end + + return table.concat(t, "\\") +end + +return getParent diff --git a/src/jest-roblox-shared/src/init.lua b/src/jest-roblox-shared/src/init.lua index 7acbf3ff..2701fd12 100644 --- a/src/jest-roblox-shared/src/init.lua +++ b/src/jest-roblox-shared/src/init.lua @@ -16,14 +16,19 @@ local nodeUtilsModule = require("./nodeUtils") export type NodeJS_WriteStream = nodeUtilsModule.NodeJS_WriteStream local exports = { + cleanLoadStringStack = require("./cleanLoadStringStack"), dedent = require("./dedent").dedent, escapePatternCharacters = require("./escapePatternCharacters").escapePatternCharacters, + ensureDirectoryExists = require("./ensureDirectoryExists"), + getDataModelService = require("./getDataModelService"), + getParent = require("./getParent"), expect = require("./expect"), getRelativePath = require("./getRelativePath"), RobloxInstance = require("./RobloxInstance"), nodeUtils = nodeUtilsModule, normalizePromiseError = require("./normalizePromiseError"), pruneDeps = require("./pruneDeps"), + redactStackTrace = require("./redactStackTrace"), } local WriteableModule = require("./Writeable") diff --git a/src/jest-roblox-shared/src/pruneDeps.lua b/src/jest-roblox-shared/src/pruneDeps.lua index 1f65ab0f..cb39ff51 100644 --- a/src/jest-roblox-shared/src/pruneDeps.lua +++ b/src/jest-roblox-shared/src/pruneDeps.lua @@ -18,6 +18,8 @@ local pruneMatch = { "@jsdotlua.promise.", } +local cleanLoadStringStack = require("./cleanLoadStringStack") + local function pruneDeps(str: string?): string? if str == nil then return nil @@ -35,6 +37,8 @@ local function pruneDeps(str: string?): string? if not matched then table.insert(newLines, line) end + line = cleanLoadStringStack(line) + table.insert(newLines, line) end return table.concat(newLines, "\n") end diff --git a/src/jest-roblox-shared/src/redactStackTrace.lua b/src/jest-roblox-shared/src/redactStackTrace.lua new file mode 100644 index 00000000..f680ed7e --- /dev/null +++ b/src/jest-roblox-shared/src/redactStackTrace.lua @@ -0,0 +1,39 @@ +--[[ + * Copyright (c) Roblox Corporation. All rights reserved. + * Licensed under the MIT License (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://opensource.org/licenses/MIT + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. +]] + +local TRACE_LINE = `[%w_%-]+%.[%w_%-%.]+%:%d+[%w \t_]*` +local REDACT_TRACE_WITH = ("\nRedacted.Stack.Trace:1337 function epicDuck"):rep(4):sub(2) +local LUA_ERROR_LINE = `[%w_%-]+%.[%w_%-%.]+%:%d+%:[%w \t_]*` +local REDACT_LUA_ERROR_WITH = "Redacted.Stack.Trace:1337: The epic duck is coming!" + +local function redactStackTrace(str: string?): string? + if str == nil then + return nil + else + local newLines = {} + local lastLineRedacted = false + for _, line in (str :: string):split("\n") do + local lineWithRedactions = line:gsub(TRACE_LINE, if lastLineRedacted then "" else REDACT_TRACE_WITH) + :gsub(LUA_ERROR_LINE, if lastLineRedacted then "" else REDACT_LUA_ERROR_WITH) + lastLineRedacted = line ~= lineWithRedactions + if not lastLineRedacted or lineWithRedactions:match("%S") then + table.insert(newLines, lineWithRedactions) + end + end + return table.concat(newLines, "\n") + end +end + +return redactStackTrace diff --git a/src/jest-runner/README.md b/src/jest-runner/README.md index 4d3ae7ea..00c8475c 100644 --- a/src/jest-runner/README.md +++ b/src/jest-runner/README.md @@ -1,19 +1,7 @@ # jest-reporters -Status: :hammer: In progress - -Source: https://github.com/facebook/jest/tree/v27.4.7/packages/jest-runner - -Version: v27.4.7 +Upstream: https://github.com/facebook/jest/tree/v27.4.7/packages/jest-runner --- ### :pencil2: Notes - -### :x: Excluded -``` -``` - -### :package: [Dependencies](https://github.com/facebook/jest/blob/v27.4.7/packages/jest-runner/package.json) -| Package | Version | Status | Notes | -| ------------------ | ------- | ------------------------- | ------------------------------------------------ | diff --git a/src/jest-runner/src/init.lua b/src/jest-runner/src/init.lua index 36fe7e71..51704fbb 100644 --- a/src/jest-runner/src/init.lua +++ b/src/jest-runner/src/init.lua @@ -20,6 +20,11 @@ type Promise = LuauPolyfill.Promise local Promise = require("@pkg/@jsdotlua/promise") +-- ROBLOX deviation START: additional function to construct file path from ModuleScript +local getDataModelService = require("@pkg/@jsdotlua/jest-roblox-shared").getDataModelService +local CoreScriptSyncService = getDataModelService("CoreScriptSyncService") +-- ROBLOX deviation END + local exports = {} -- ROBLOX deviation: chalk used only in parallel tests @@ -174,6 +179,9 @@ function TestRunner:_createInBandTestRun( -- process.env.JEST_WORKER_ID = "1" local mutex = throat(1) :: ThroatLateBound return Array.reduce(tests, function(promise: Promise, test: Test) + if CoreScriptSyncService then + test.path = CoreScriptSyncService:GetScriptFilePath(test.script) + end return mutex(function() -- ROBLOX FIXME START: Promise type doesn't support changing return type with :andThen call return (promise :: Promise) diff --git a/src/jest-runner/src/runTest.lua b/src/jest-runner/src/runTest.lua index 0f153891..a898a2e8 100644 --- a/src/jest-runner/src/runTest.lua +++ b/src/jest-runner/src/runTest.lua @@ -18,6 +18,8 @@ type Promise = LuauPolyfill.Promise -- ROBLOX deviation START: additional function to construct file path from ModuleScript local getRelativePath = require("@pkg/@jsdotlua/jest-roblox-shared").getRelativePath +local getDataModelService = require("@pkg/@jsdotlua/jest-roblox-shared").getDataModelService +local CoreScriptSyncService = getDataModelService("CoreScriptSyncService") -- ROBLOX deviation END local exports = {} @@ -231,9 +233,10 @@ local function runTestInternal( setGlobal((environment.global :: unknown) :: typeof(_G), "console", testConsole) local runtime = Runtime.new( + projectConfig, loadedModuleFns -- ROBLOX TODO START: no params to Runtime.new so far - -- config, environment, resolver, transformer, cacheFS, { + -- environment, resolver, transformer, cacheFS, { -- changedFiles = if typeof(context) == "table" then context.changedFiles else nil, -- collectCoverage = globalConfig.collectCoverage, -- collectCoverageFrom = globalConfig.collectCoverageFrom, @@ -366,7 +369,12 @@ local function runTestInternal( slow = testRuntime / 1000 > projectConfig.slowTestThreshold, start = start, } - result.testFilePath = getRelativePath(path, projectConfig.rootDir) + -- ROBLOX deviation: resolve to a FS path if CoreScriptSyncService is available + if CoreScriptSyncService then + result.testFilePath = CoreScriptSyncService:GetScriptFilePath(path) + else + result.testFilePath = getRelativePath(path, projectConfig.rootDir) + end result.console = testConsole:getBuffer() result.skipped = testCount == result.numPendingTests result.displayName = projectConfig.displayName diff --git a/src/jest-runtime/README.md b/src/jest-runtime/README.md index 513fee8f..06d500d8 100644 --- a/src/jest-runtime/README.md +++ b/src/jest-runtime/README.md @@ -1,19 +1,7 @@ # jest-runtime -Status: :heavy_check_mark: Ported - -Source: https://github.com/facebook/jest/tree/v27.4.7/packages/jest-runtime - -Version: v27.4.7 +Upstream: https://github.com/facebook/jest/tree/v27.4.7/packages/jest-runtime --- ### :pencil2: Notes - -### :x: Excluded -``` -``` - -### :package: [Dependencies](https://github.com/facebook/jest/blob/v27.4.7/packages/jest-runtime/package.json) -| Package | Version | Status | Notes | -| ------------------ | ------- | ------------------------- | ------------------------------------------------ | diff --git a/src/jest-runtime/package.json b/src/jest-runtime/package.json index a59878fb..796042d5 100644 --- a/src/jest-runtime/package.json +++ b/src/jest-runtime/package.json @@ -16,12 +16,14 @@ "@jsdotlua/expect": "workspace:^", "@jsdotlua/jest-fake-timers": "workspace:^", "@jsdotlua/jest-mock": "workspace:^", + "@jsdotlua/jest-mock-genv": "workspace:^", "@jsdotlua/jest-snapshot": "workspace:^", "@jsdotlua/jest-types": "workspace:^", "@jsdotlua/luau-polyfill": "^1.2.6", "@jsdotlua/promise": "^3.5.0" }, "devDependencies": { + "@jsdotlua/jest-config": "workspace:^", "@jsdotlua/jest-globals": "workspace:^", "npmluau": "^0.1.1" } diff --git a/src/jest-runtime/src/__mocks__/createRuntime.lua b/src/jest-runtime/src/__mocks__/createRuntime.lua index 68d77400..4f6b499d 100644 --- a/src/jest-runtime/src/__mocks__/createRuntime.lua +++ b/src/jest-runtime/src/__mocks__/createRuntime.lua @@ -1,3 +1,4 @@ +--!strict -- ROBLOX upstream: https://github.com/facebook/jest/blob/v28.0.0/jest-runtime/src/__mocks__/createRuntime.js --[[* * Copyright (c) Facebook, Inc. and its affiliates. All Rights Reserved. @@ -55,10 +56,16 @@ local Runtime = require("..") -- Copy from jest-config (since we don't want to d -- return { { "^.+\\.[jt]sx?$", require_:resolve("babel-jest") } } -- end -- ROBLOX deviation END + +-- ROBLOX deviation START: get config types +local JestTypes = require("@pkg/@jsdotlua/jest-types") +type Config_ProjectConfig = JestTypes.Config_ProjectConfig +-- ROBLOX deviation END + return function( -- ROBLOX deviation START: arguments not needed. filename only kept to preserve this createRuntime's call api -- self: any, - filename: ModuleScript - -- config + filename: ModuleScript, + config: Config_ProjectConfig -- ROBLOX deviation END ) return Promise.resolve():andThen(function() @@ -127,8 +134,7 @@ return function( -- ROBLOX deviation START: arguments not needed. filename only -- end -- runtime.__mockRootPath = Array.join(path, config.rootDir, "root.js") --[[ ROBLOX CHECK: check if 'path' is an Array ]] -- runtime.__mockSubdirPath = Array.join(path, config.rootDir, "subdir2", "module_dir", "module_dir_module.js") --[[ ROBLOX CHECK: check if 'path' is an Array ]] - local runtime = Runtime.new(); - -- ROBLOX NOTE: this is JS file upstream so no type checking is performed + local runtime = Runtime.new(config); -- ROBLOX NOTE: this is JS file upstream so no type checking is performed (runtime :: any).__mockRootPath = script.Parent.Parent.__tests__.test_root -- ROBLOX deviation END return runtime diff --git a/src/jest-runtime/src/__tests__/require.roblox.spec.lua b/src/jest-runtime/src/__tests__/require.roblox.spec.lua index 2ff0ab83..a2c9b5a7 100644 --- a/src/jest-runtime/src/__tests__/require.roblox.spec.lua +++ b/src/jest-runtime/src/__tests__/require.roblox.spec.lua @@ -5,11 +5,15 @@ local JestGlobals = require("@pkg/@jsdotlua/jest-globals") local expect = JestGlobals.expect local it = JestGlobals.it +local JestConfig = require("@pkg/@jsdotlua/jest-config") + local Runtime = require("../init") +type FIXME_ANALYZE = any + it("should not allow ModuleScripts returning zero values", function() expect(function() - local _requireZero = (require("./requireZero.roblox.lua") :: any) + local _requireZero = require(script.Parent["requireZero.roblox"]) :: any end).toThrow("ModuleScripts must return exactly one value") end) @@ -34,11 +38,29 @@ end) it("should not override module function environment for another runtime", function() local loadedModuleFns = Map.new() - local returnRequire = Runtime.new(loadedModuleFns):requireModule(script.Parent["returnRequire.roblox"]) + local returnRequire = Runtime.new(JestConfig.projectDefaults, loadedModuleFns) + :requireModule(script.Parent["returnRequire.roblox"]) local requireRefBefore = returnRequire() - Runtime.new(loadedModuleFns):requireModule(script.Parent["returnRequire.roblox"]) + Runtime.new(JestConfig.projectDefaults, loadedModuleFns):requireModule(script.Parent["returnRequire.roblox"]) local requireRefAfter = returnRequire() expect(requireRefBefore).toBe(requireRefAfter) +end); + +(it.each :: FIXME_ANALYZE)({ + "", + "/", + "foo", + "/foo", + "foo/bar", + "/foo/bar", + "@alias", + "@", + "@alias/foo", + "@alias/foo/bar", +})("should explicitly disallow all forms of require by string", function(path) + expect(function() + (require :: any)(path) + end).toThrow("not enabled") end) diff --git a/src/jest-runtime/src/__tests__/requireActual.roblox.spec.lua b/src/jest-runtime/src/__tests__/requireActual.roblox.spec.lua index da2d1ddf..98d9b10c 100644 --- a/src/jest-runtime/src/__tests__/requireActual.roblox.spec.lua +++ b/src/jest-runtime/src/__tests__/requireActual.roblox.spec.lua @@ -7,10 +7,14 @@ local it = JestGlobals.it local beforeEach = JestGlobals.beforeEach local mockMeScript = script.Parent.mock_me +local JestConfig = require("@pkg/@jsdotlua/jest-config") + local rootJsPath = script.Parent.test_root.root local __filename = mockMeScript local createRuntime +type FIXME_ANALYZE = any + describe("Roblox requireActual", function() beforeEach(function() createRuntime = require("../__mocks__/createRuntime") @@ -18,7 +22,7 @@ describe("Roblox requireActual", function() it("should mock module and then require the actual module", function() return Promise.resolve():andThen(function() - local runtime = createRuntime(__filename):expect() + local runtime = createRuntime(__filename, JestConfig.projectDefaults):expect() local root = runtime:requireModule(runtime.__mockRootPath, rootJsPath) -- Erase module registry because root.js requires most other modules. root.jest.mock(mockMeScript, function() @@ -39,7 +43,7 @@ describe("Roblox requireActual", function() it("should mock module using part of the actual module", function() return Promise.resolve():andThen(function() - local runtime = createRuntime(__filename):expect() + local runtime = createRuntime(__filename, JestConfig.projectDefaults):expect() local root = runtime:requireModule(runtime.__mockRootPath, rootJsPath) -- Erase module registry because root.js requires most other modules. -- this should resemble a typical usage of requireActual: i.e. you mock a module, but you also use parts of the actual module in the mock @@ -54,5 +58,27 @@ describe("Roblox requireActual", function() expect(runtime:requireModuleOrMock(mockMeScript).mocked).toEqual(true) expect(runtime:requireModuleOrMock(mockMeScript).actual).toEqual(true) end) + end); + + (it.each :: FIXME_ANALYZE)({ + "", + "/", + "foo", + "/foo", + "foo/bar", + "/foo/bar", + "@alias", + "@", + "@alias/foo", + "@alias/foo/bar", + })("should explicitly disallow all forms of require by string", function(path) + return Promise.resolve():andThen(function() + local runtime = createRuntime(__filename, JestConfig.projectDefaults):expect() + local root = runtime:requireModule(runtime.__mockRootPath, rootJsPath) -- Erase module registry because root.js requires most other modules. + + expect(function() + root.jest.requireActual(path) + end).toThrow("not enabled") + end) end) end) diff --git a/src/jest-runtime/src/__tests__/runtime.spec.lua b/src/jest-runtime/src/__tests__/runtime.spec.lua index 25be2e11..456e3d5c 100644 --- a/src/jest-runtime/src/__tests__/runtime.spec.lua +++ b/src/jest-runtime/src/__tests__/runtime.spec.lua @@ -21,6 +21,8 @@ local it = JestGlobals.it local beforeAll = JestGlobals.beforeAll local afterAll = JestGlobals.afterAll +local JestConfig = require("@pkg/@jsdotlua/jest-config") + local JestRuntime = require("../init") -- ROBLOX TODO: using RuntimePrivate type until better approach is found type JestRuntime = JestRuntime.Runtime @@ -38,7 +40,7 @@ local requireOverride = function(scriptInstance: ModuleScript) end beforeAll(function() - _runtime = JestRuntime.new() + _runtime = JestRuntime.new(JestConfig.projectDefaults) end) afterAll(function() diff --git a/src/jest-runtime/src/__tests__/runtime_mock.test.lua b/src/jest-runtime/src/__tests__/runtime_mock.test.lua index 4da6fec1..546e627a 100644 --- a/src/jest-runtime/src/__tests__/runtime_mock.test.lua +++ b/src/jest-runtime/src/__tests__/runtime_mock.test.lua @@ -1,6 +1,8 @@ -- ROBLOX upstream: https://github.com/facebook/jest/blob/v28.0.0/jest-runtime/src/__tests__/runtime_mock.test.js local Promise = require("@pkg/@jsdotlua/promise") local JestGlobals = require("@pkg/@jsdotlua/jest-globals") +-- ROBLOX deviation: pass config to runtime new +local JestConfig = require("@pkg/@jsdotlua/jest-config") local beforeEach = JestGlobals.beforeEach local describe = JestGlobals.describe local expect = JestGlobals.expect @@ -33,8 +35,9 @@ describe("Runtime", function() -- ROBLOX deviation END it("uses explicitly set mocks instead of automocking", function() return Promise.resolve():andThen(function() - -- ROBLOX deviation START: using current ModuleScript instead of __filename - local runtime = createRuntime(__filename):expect() + -- ROBLOX deviation START: using current ModuleScript instead of + -- __filename, also pass in config to runtime + local runtime = createRuntime(__filename, JestConfig.projectDefaults):expect() -- ROBLOX deviation END local mockReference = { isMock = true } local root = runtime:requireModule(runtime.__mockRootPath, rootJsPath) -- Erase module registry because root.js requires most other modules. @@ -69,7 +72,8 @@ describe("Runtime", function() -- ROBLOX deviation START: virtual mocks not supported -- it("sets virtual mock for non-existing module required from same directory", function() -- return Promise.resolve():andThen(function() - -- local runtime = createRuntime(__filename):expect() + + -- local runtime = createRuntime(__filename, config):expect() -- local mockReference = { isVirtualMock = true } -- local virtual = true -- local root = runtime:requireModule(runtime.__mockRootPath, rootJsPath) -- Erase module registry because root.js requires most other modules. @@ -90,7 +94,8 @@ describe("Runtime", function() -- end) -- it("sets virtual mock for non-existing module required from different directory", function() -- return Promise.resolve():andThen(function() - -- local runtime = createRuntime(__filename):expect() + + -- local runtime = createRuntime(__filename, config):expect() -- local mockReference = { isVirtualMock = true } -- local virtual = true -- local root = runtime:requireModule(runtime.__mockRootPath, rootJsPath) -- Erase module registry because root.js requires most other modules. @@ -134,7 +139,8 @@ describe("Runtime", function() describe("jest.setMock", function() it("uses explicitly set mocks instead of automocking", function() return Promise.resolve():andThen(function() - local runtime = createRuntime(__filename):expect() + -- ROBLOX deviation: pass config to runtime new + local runtime = createRuntime(__filename, JestConfig.projectDefaults):expect() local mockReference = { isMock = true } local root = runtime:requireModule(runtime.__mockRootPath, rootJsPath) -- Erase module registry because root.js requires most other modules. root.jest.resetModules() diff --git a/src/jest-runtime/src/init.lua b/src/jest-runtime/src/init.lua index 1d8fac4b..ad1feb4d 100644 --- a/src/jest-runtime/src/init.lua +++ b/src/jest-runtime/src/init.lua @@ -1,3 +1,4 @@ +--!nonstrict -- ROBLOX upstream: https://github.com/facebook/jest/blob/v28.0.0/packages/jest-runtime/src/index.ts --[[* * Copyright (c) Facebook, Inc. and its affiliates. All Rights Reserved. @@ -83,10 +84,10 @@ type Omit = T --[[ ROBLOX TODO: TS 'Omit' built-in type is not available i -- local TransformationOptions = jestTransformModule.TransformationOptions -- local handlePotentialSyntaxError = jestTransformModule.handlePotentialSyntaxError -- local shouldInstrument = jestTransformModule.shouldInstrument --- local jestTypesModule = require("@pkg/@jsdotlua/jest-types") +local jestTypesModule = require("@pkg/@jsdotlua/jest-types") -- type Config = jestTypesModule.Config -- type Config_Path = jestTypesModule.Config_Path --- type Config_ProjectConfig = jestTypesModule.Config_ProjectConfig +type Config_ProjectConfig = jestTypesModule.Config_ProjectConfig -- type Global = jestTypesModule.Global -- type Global_TestFrameworkGlobals = jestTypesModule.Global_TestFrameworkGlobals -- local jestHasteMapModule = require("@pkg/jest-haste-map") @@ -101,10 +102,13 @@ local jestMockModule = require("@pkg/@jsdotlua/jest-mock") -- type MockFunctionMetadata = jestMockModule.MockFunctionMetadata -- ROBLOX deviation END type ModuleMocker = jestMockModule.ModuleMocker - -- ROBLOX deviation START: (addition) importing ModuleMocker class instead of injecting it via runTests local ModuleMocker = jestMockModule.ModuleMocker -- ROBLOX deviation END +-- ROBLOX deviation START: mocking globals +local jestMockGenvModule = require("@pkg/@jsdotlua/jest-mock-genv") +local GlobalMocker = jestMockGenvModule.GlobalMocker +type GlobalMocker = jestMockGenvModule.GlobalMocker -- ROBLOX deviation START: skipped -- local escapePathForRegex = require("@pkg/jest-regex-util").escapePathForRegex -- local jestResolveModule = require("@pkg/jest-resolve") @@ -184,6 +188,7 @@ type InternalModuleOptions = Object -- supportsTopLevelAwait = false, -- } -- ROBLOX deviation END + -- ROBLOX deviation START: custom type implementation -- type InitialModule = Omit type Module = { @@ -283,23 +288,12 @@ export type Runtime = { -- unstable as it should be replaced by https://github.c -- unstable_importModule: (self: Runtime, from: Config_Path, moduleName: string?) -> Promise, -- ROBLOX deviation END -- ROBLOX deviation START: using ModuleScript instead of string - -- requireModule: ( - -- self: Runtime, - -- from: Config_Path, - -- moduleName: string?, - -- options: InternalModuleOptions?, - -- isRequireActual_: boolean? - -- ) -> T, - -- requireInternalModule: (self: Runtime, from: Config_Path, to: string?) -> T, - -- requireActual: (self: Runtime, from: Config_Path, moduleName: string) -> T, - -- requireMock: (self: Runtime, from: Config_Path, moduleName: string) -> T, requireModule: ( self: Runtime, from: ModuleScript, moduleName: ModuleScript?, options: InternalModuleOptions?, isRequireActual_: boolean?, - -- ROBLOX NOTE: additional param to not require return from test files noModuleReturnRequired: boolean? ) -> T, @@ -308,7 +302,6 @@ export type Runtime = { -- unstable as it should be replaced by https://github.c requireMock: (self: Runtime, from: ModuleScript, moduleName: ModuleScript) -> T, -- ROBLOX deviation END -- ROBLOX deviation START: modified signature, use ModuleScript instead of string - -- requireModuleOrMock: (self: Runtime, from: Config_Path, moduleName: string) -> T, requireModuleOrMock: (self: Runtime, moduleName: ModuleScript) -> T, -- ROBLOX deviation END isolateModules: (self: Runtime, fn: () -> ()) -> (), @@ -325,8 +318,6 @@ export type Runtime = { -- unstable as it should be replaced by https://github.c setMock: ( self: Runtime, -- ROBLOX deviation START: using ModuleScript instead of string - -- from: string, - -- moduleName: string, from: ModuleScript, moduleName: ModuleScript, -- ROBLOX deviation END @@ -347,16 +338,6 @@ type Runtime_private = { -- -- unstable_importModule: (self: Runtime_private, from: Config_Path, moduleName: string?) -> Promise, -- ROBLOX deviation END -- ROBLOX deviation START: using ModuleScript instead of string - -- requireModule: ( - -- self: Runtime_private, - -- from: Config_Path, - -- moduleName: string?, - -- options: InternalModuleOptions?, - -- isRequireActual_: boolean? - -- ) -> T, - -- requireInternalModule: (self: Runtime_private, from: Config_Path, to: string?) -> T, - -- requireActual: (self: Runtime_private, from: Config_Path, moduleName: string) -> T, - -- requireMock: (self: Runtime_private, from: Config_Path, moduleName: string) -> T, requireModule: ( self: Runtime_private, from: ModuleScript, @@ -372,7 +353,6 @@ type Runtime_private = { -- requireMock: (self: Runtime_private, from: ModuleScript, moduleName: ModuleScript) -> T, -- ROBLOX deviation END -- ROBLOX deviation START: modified signature, use ModuleScript instead of string - -- requireModuleOrMock: (self: Runtime_private, from: Config_Path, moduleName: string) -> T, requireModuleOrMock: (self: Runtime_private, moduleName: ModuleScript) -> T, -- ROBLOX deviation END isolateModules: (self: Runtime_private, fn: () -> ()) -> (), @@ -389,8 +369,6 @@ type Runtime_private = { -- setMock: ( self: Runtime_private, -- ROBLOX deviation START: using ModuleScript instead of string - -- from: string, - -- moduleName: string, from: ModuleScript, moduleName: ModuleScript, -- ROBLOX deviation END @@ -407,7 +385,7 @@ type Runtime_private = { -- -- -- ROBLOX deviation START: skipped -- _cacheFS: Map, - -- _config: Config_ProjectConfig, + _config: Config_ProjectConfig, -- _coverageOptions: ShouldInstrumentOptions, -- _currentlyExecutingModulePath: string, -- ROBLOX deviation END @@ -456,6 +434,8 @@ type Runtime_private = { -- -- _moduleMockFactories: Map unknown>, -- ROBLOX deviation END _moduleMocker: ModuleMocker, + -- ROBLOX deviation: mocking globals + _globalMocker: GlobalMocker, _isolatedModuleRegistry: ModuleRegistry | nil, _moduleRegistry: ModuleRegistry, -- ROBLOX deviation START: skipped @@ -513,7 +493,7 @@ type Runtime_private = { -- -- importMock: ( -- self: Runtime_private, -- from: Config_Path, - -- moduleName: string, + -- moduleName: ModuleScript, -- context: VMContext -- ) -> Promise, -- getExportsOfCjs: (self: Runtime_private, modulePath: Config_Path) -> any, @@ -522,9 +502,6 @@ type Runtime_private = { -- self: Runtime_private, localModule: InitialModule, -- ROBLOX deviation START: using ModuleScript instead of string - -- from: Config_Path, - -- moduleName: string | nil, - -- modulePath: Config_Path, from: ModuleScript, moduleName: ModuleScript | nil, modulePath: ModuleScript, @@ -543,8 +520,10 @@ type Runtime_private = { -- -- ROBLOX deviation END setModuleMock: ( self: Runtime_private, - from: string, - moduleName: string, + -- ROBLOX deviation START: using ModuleScript instead of string + from: ModuleScript, + moduleName: ModuleScript, + -- ROBLOX deviation END mockFactory: () -> Promise | unknown, options: { virtual: boolean? }? ) -> (), @@ -558,10 +537,10 @@ type Runtime_private = { -- -- _requireResolve: ( -- self: Runtime_private, -- from: Config_Path, - -- moduleName: string?, + -- moduleName: ModuleScript?, -- options_: ResolveOptions? -- ) -> any, - -- _requireResolvePaths: (self: Runtime_private, from: Config_Path, moduleName: string?) -> any, + -- _requireResolvePaths: (self: Runtime_private, from: Config_Path, moduleName: ModuleScript?) -> any, -- ROBLOX deviation END _execModule: ( self: Runtime_private, @@ -584,19 +563,16 @@ type Runtime_private = { -- -- options: InternalModuleOptions? -- ) -> Promise, -- createScriptFromCode: (self: Runtime_private, scriptSource: string, filename: string) -> any, - -- _requireCoreModule: (self: Runtime_private, moduleName: string, supportPrefix: boolean) -> any, - -- _importCoreModule: (self: Runtime_private, moduleName: string, context: VMContext) -> any, + -- _requireCoreModule: (self: Runtime_private, moduleName: ModuleScript, supportPrefix: boolean) -> any, + -- _importCoreModule: (self: Runtime_private, moduleName: ModuleScript, context: VMContext) -> any, -- _getMockedNativeModule: ( -- self: Runtime_private -- ) -> typeof(__unhandledIdentifier__ --[[ ROBLOX TODO: Unhandled node for type: TSQualifiedName ]] --[[ nativeModule.Module ]]), - -- _generateMock: (self: Runtime_private, from: Config_Path, moduleName: string) -> any, + -- _generateMock: (self: Runtime_private, from: Config_Path, moduleName: ModuleScript) -> any, -- ROBLOX deviation END _shouldMock: ( self: Runtime_private, -- ROBLOX deviation: accept ModuleScript instead of string - -- from: Config_Path, - -- moduleName: string, - -- explicitShouldMock: Map, from: ModuleScript, moduleName: ModuleScript, explicitShouldMock: Map, @@ -659,7 +635,7 @@ local Runtime_private = Runtime :: Runtime_private & Runtime_statics; -- coverageOptions: ShouldInstrumentOptions, -- testPath: Config_Path -- ): Runtime -function Runtime.new(loadedModuleFns: Map?): Runtime +function Runtime.new(config: Config_ProjectConfig, loadedModuleFns: Map?): Runtime -- ROBLOX deviation START: cast to private type to access methods properly -- local self = setmetatable({}, Runtime) local self = (setmetatable({}, Runtime) :: any) :: Runtime_private @@ -667,7 +643,7 @@ function Runtime.new(loadedModuleFns: Map?): Runtime self.isTornDown = false -- ROBLOX deviation START: skipped -- self._cacheFS = cacheFS - -- self._config = config + self._config = config -- self._coverageOptions = coverageOptions -- self._currentlyExecutingModulePath = "" -- ROBLOX deviation END @@ -699,7 +675,14 @@ function Runtime.new(loadedModuleFns: Map?): Runtime -- ROBLOX deviation END -- ROBLOX deviation START: instantiate the module mocker here instead of being passed in as an arg from runTest -- self._moduleMocker = self._environment.moduleMocker - self._moduleMocker = ModuleMocker.new() + self._moduleMocker = ModuleMocker.new(config) + -- ROBLOX deviation END + -- ROBLOX deviation START: mocking globals + self._globalMocker = GlobalMocker.new(jestMockGenvModule.MOCKABLE_GLOBALS) + -- TODO: if we want to mock more specific function environment members then + -- this will have to be rethought, but for what's being mocked now, it's + -- fine to draw from the global function environment. + self._moduleMocker:mockGlobals(self._globalMocker, getfenv(0)) -- ROBLOX deviation END self._isolatedModuleRegistry = nil self._isolatedMockRegistry = nil @@ -1069,7 +1052,7 @@ end -- return module -- end) -- end --- function Runtime_private:unstable_importModule(from: Config_Path, moduleName: string?): Promise +-- function Runtime_private:unstable_importModule(from: Config_Path, moduleName: ModuleScript?): Promise -- return Promise.resolve():andThen(function() -- invariant( -- runtimeSupportsVmModules, @@ -1101,7 +1084,7 @@ end -- end, { context = context, identifier = modulePath }) -- return evaluateSyntheticModule(module) -- end --- function Runtime_private:importMock(from: Config_Path, moduleName: string, context: VMContext): Promise +-- function Runtime_private:importMock(from: Config_Path, moduleName: ModuleScript, context: VMContext): Promise -- return Promise.resolve():andThen(function() -- local moduleID = -- self._resolver:getModuleID(self._virtualModuleMocks, from, moduleName, { conditions = self.esmConditions }) @@ -1151,8 +1134,6 @@ end -- ROBLOX deviation END function Runtime_private:requireModule( -- ROBLOX deviation START: using ModuleScript instead of string - -- from: Config_Path, - -- moduleName: string?, from: ModuleScript, moduleName_: ModuleScript?, -- ROBLOX deviation END @@ -1313,13 +1294,12 @@ function Runtime_private:requireInternalModule(from: ModuleScript, to: Module }) end -- ROBLOX deviation START: using ModuleScript instead of string --- function Runtime_private:requireActual(from: Config_Path, moduleName: string): T +-- function Runtime_private:requireActual(from: Config_Path, moduleName: ModuleScript): T function Runtime_private:requireActual(from: ModuleScript, moduleName: ModuleScript): T -- ROBLOX deviation END return self:requireModule(from, moduleName, nil, true) end -- ROBLOX deviation START: using ModuleScript instead of string --- function Runtime_private:requireMock(from: Config_Path, moduleName: string): T -- local moduleID = -- self._resolver:getModuleID(self._virtualMocks, from, moduleName, { conditions = self.cjsConditions }) function Runtime_private:requireMock(from: ModuleScript, moduleName: ModuleScript): T @@ -1405,9 +1385,6 @@ end function Runtime_private:_loadModule( localModule: InitialModule, -- ROBLOX deviation START: using ModuleScript instead of string - -- from: Config_Path, - -- moduleName: string | nil, - -- modulePath: Config_Path, from: ModuleScript, moduleName: ModuleScript | nil, modulePath: ModuleScript, @@ -1446,12 +1423,11 @@ end -- end -- ROBLOX deviation END -- ROBLOX deviation START: modified signature, use ModuleScript instead of string --- function Runtime_private:requireModuleOrMock(from: Config_Path, moduleName: string): T function Runtime_private:requireModuleOrMock(moduleName: ModuleScript): T local from = moduleName -- ROBLOX deviation END -- ROBLOX deviation START: additional interception - if moduleName == script or moduleName == script.Parent then + if moduleName == script or (typeof(script.Parent) == "ModuleScript" and moduleName == script.Parent) then -- ROBLOX NOTE: Need to cast require because analyze cannot figure out scriptInstance path return (require :: any)(moduleName) end @@ -1685,8 +1661,6 @@ end -- ROBLOX deviation END function Runtime_private:setMock( -- ROBLOX deviation START: using module script instead of string moduleName - -- from: string, - -- moduleName: string, from: ModuleScript, moduleName: ModuleScript, -- ROBLOX deviation END @@ -1711,7 +1685,7 @@ end -- ROBLOX deviation START: skipped -- function Runtime_private:setModuleMock( -- from: string, --- moduleName: string, +-- moduleName: ModuleScript, -- mockFactory: () -> Promise | unknown, -- options: { virtual: boolean? }? -- ): () @@ -1735,6 +1709,8 @@ function Runtime_private:clearAllMocks(): () self._moduleMocker:clearAllMocks() end function Runtime_private:teardown(): () + -- ROBLOX deviation: mocking globals + self._moduleMocker:unmockGlobals(self._globalMocker) self:restoreAllMocks() self:resetAllMocks() self:resetModules() @@ -1779,7 +1755,7 @@ end -- function Runtime_private:_resolveModule(from: Config_Path, to: string | nil, options: ResolveModuleConfig?) -- return if Boolean.toJSBoolean(to) then self._resolver:resolveModule(from, to, options) else from -- end --- function Runtime_private:_requireResolve(from: Config_Path, moduleName: string?, options_: ResolveOptions?) +-- function Runtime_private:_requireResolve(from: Config_Path, moduleName: ModuleScript?, options_: ResolveOptions?) -- local options: ResolveOptions = if options_ ~= nil then options_ else {} -- if -- moduleName == nil --[[ ROBLOX CHECK: loose equality used upstream ]] @@ -1836,7 +1812,7 @@ end -- end -- end -- end --- function Runtime_private:_requireResolvePaths(from: Config_Path, moduleName: string?) +-- function Runtime_private:_requireResolvePaths(from: Config_Path, moduleName: ModuleScript?) -- if -- moduleName == nil --[[ ROBLOX CHECK: loose equality used upstream ]] -- then @@ -1966,6 +1942,7 @@ function Runtime_private:_execModule( local moduleFunction, defaultEnvironment, errorMessage, cleanupFn local modulePath = localModule.filename + local loadModuleEnabled = pcall((debug :: any).loadmodule, Instance.new("ModuleScript")) if self._loadedModuleFns and self._loadedModuleFns:has(modulePath) then local loadedModule = self._loadedModuleFns:get(modulePath) :: { any } @@ -1974,8 +1951,12 @@ function Runtime_private:_execModule( else -- Narrowing this type here lets us appease the type checker while still -- counting on types for the rest of this file - local loadmodule: (ModuleScript) -> (any, string, () -> any) = debug["loadmodule"] - moduleFunction, errorMessage, cleanupFn = loadmodule(modulePath) + if loadModuleEnabled then + local loadmodule: (ModuleScript) -> (any, string, () -> any) = debug["loadmodule"] + moduleFunction, errorMessage, cleanupFn = loadmodule(modulePath) + else + moduleFunction = loadstring(modulePath.Source, modulePath:GetFullName()) + end -- ROBLOX NOTE: we are not using assert() as it throws a bare string and we need to throw an Error object if moduleFunction == nil then error(Error.new(errorMessage)) @@ -1998,44 +1979,86 @@ function Runtime_private:_execModule( -- a new module instance but with the same environment table as `moduleFunction` itself at the -- time of invocation. In order to properly sanbox module instances, we need to ensure that -- each instance has its own distinct environment table containing the specific overrides for it, - -- but still inherits from the default parent environment for non-overriden environment goodies. + -- but still inherits from the default parent environment for non-overriden + -- environment goodies. -- local isInternal = false -- if options ~= nil and options.isInternalModule then options.isInternalModule else false local isInternal = if options ~= nil and options.isInternalModule then options.isInternalModule else false - setfenv( - moduleFunction, - setmetatable( - Object.assign( - { - --[[ - ROBLOX NOTE: - Adding `script` directly into a table so that it is accessible to the debugger - It seems to be a similar issue to code inside of __index function not being debuggable - ]] - script = defaultEnvironment.script, - require = if isInternal - then function(scriptInstance: ModuleScript) - return self:requireInternalModule(scriptInstance) - end - else function(scriptInstance: ModuleScript) - return self:requireModuleOrMock(scriptInstance) - end, - }, - if isInternal - then {} - else { - delay = self._fakeTimersImplementation.delayOverride, - tick = self._fakeTimersImplementation.tickOverride, - time = self._fakeTimersImplementation.timeOverride, - DateTime = self._fakeTimersImplementation.dateTimeOverride, - os = self._fakeTimersImplementation.osOverride, - task = self._fakeTimersImplementation.taskOverride, - } - ) :: Object, - { __index = defaultEnvironment } - ) :: any - ) + -- This is the 'least mocked' environment that scripts will be able to see. + -- The final function environment inherits from this sandbox. + -- This is separate so that, in the future, `globalEnv` could expose these + -- 'unmocked' functions instead of the ones in the global environment. + local sandboxEnvironment = setmetatable({ + --[[ + ROBLOX NOTE: + Adding `script` directly into a table so that it is accessible to the debugger + It seems to be a similar issue to code inside of __index function not being debuggable + ]] + script = if loadModuleEnabled then defaultEnvironment.script else modulePath, + require = if isInternal + then function(scriptInstance: ModuleScript | string) + if typeof(scriptInstance) == "string" then + -- Disabling this at the surface level of the API until we have + -- deeper support in Jest. + error("Require-by-string is not enabled for use inside Jest at this time.") + end + return self:requireInternalModule(scriptInstance) + end + else function(scriptInstance: ModuleScript | string) + if typeof(scriptInstance) == "string" then + -- Disabling this at the surface level of the API until we have + -- deeper support in Jest. + error("Require-by-string is not enabled for use inside Jest at this time.") + end + return self:requireModuleOrMock(scriptInstance) + end, + }, { + __index = defaultEnvironment, + }) + if not isInternal then + Object.assign(sandboxEnvironment, { + delay = self._fakeTimersImplementation.delayOverride, + tick = self._fakeTimersImplementation.tickOverride, + time = self._fakeTimersImplementation.timeOverride, + DateTime = self._fakeTimersImplementation.dateTimeOverride, + os = self._fakeTimersImplementation.osOverride, + task = self._fakeTimersImplementation.taskOverride, + }) + end + + -- This is the environment actually passed to scripts, including all global + -- mocks and other customisations the user might choose to apply. + local mockedSandboxEnvironment = setmetatable({}, { + __index = sandboxEnvironment, + }) + local function setupAutomocks(automocks: Object, sourceEnv: any, saveInto: any) + for name, automock in automocks do + if automock._isGlobalAutomockFn then + local original = sourceEnv[name] + -- Disguise the mock callable table as a function on the + -- outside, so it retains the same behaviour when observed in + -- various ways by almost all code (except debug library stuff) + saveInto[name] = function(...) + if automock._maybeMock == nil then + error(Error.new("Code should not be running when globalEnv is uninitialised")) + end + return automock._maybeMock(...) + end + else + local subSourceEnv = sourceEnv[name] + local subSaveInto = setmetatable({}, { + __index = subSourceEnv, + }) + saveInto[name] = subSaveInto + setupAutomocks(automock, subSourceEnv, subSaveInto) + end + end + end + setupAutomocks(self._globalMocker.automocks, sandboxEnvironment, mockedSandboxEnvironment) + + setfenv(moduleFunction, mockedSandboxEnvironment :: any) local moduleResult = table.pack(moduleFunction()) + if moduleResult.n ~= 1 and noModuleReturnRequired ~= true then error( string.format( @@ -2120,7 +2143,7 @@ end -- end -- end -- end --- function Runtime_private:_requireCoreModule(moduleName: string, supportPrefix: boolean) +-- function Runtime_private:_requireCoreModule(moduleName: ModuleScript, supportPrefix: boolean) -- local moduleWithoutNodePrefix = if Boolean.toJSBoolean( -- if Boolean.toJSBoolean(supportPrefix) then moduleName:startsWith("node:") else supportPrefix -- ) @@ -2134,7 +2157,7 @@ end -- end -- return require_(moduleWithoutNodePrefix) -- end --- function Runtime_private:_importCoreModule(moduleName: string, context: VMContext) +-- function Runtime_private:_importCoreModule(moduleName: ModuleScript, context: VMContext) -- local required = self:_requireCoreModule(moduleName, supportsNodeColonModulePrefixInImport) -- local module = SyntheticModule.new( -- Array.concat({}, { "default" }, Array.spread(Object.keys(required))), @@ -2219,7 +2242,7 @@ end -- self._moduleImplementation = Module -- return Module -- end --- function Runtime_private:_generateMock(from: Config_Path, moduleName: string) +-- function Runtime_private:_generateMock(from: Config_Path, moduleName: ModuleScript) -- local ref = self._resolver:resolveStubModuleName(from, moduleName) -- local modulePath = Boolean.toJSBoolean(ref) and ref -- or self:_resolveModule(from, moduleName, { conditions = self.cjsConditions }) @@ -2259,9 +2282,6 @@ end -- ROBLOX deviation END function Runtime_private:_shouldMock( -- ROBLOX deviation: accept ModuleScript instead of string - -- from: Config_Path, - -- moduleName: string, - -- explicitShouldMock: Map, from: ModuleScript, moduleName: ModuleScript, explicitShouldMock: Map, @@ -2351,7 +2371,7 @@ function Runtime_private:_shouldMock( end -- ROBLOX deviation START: skipped -- function Runtime_private:_createRequireImplementation(from: InitialModule, options: InternalModuleOptions?): NodeRequire --- local function resolve(moduleName: string, resolveOptions: ResolveOptions?) +-- local function resolve(moduleName: ModuleScript, resolveOptions: ResolveOptions?) -- local resolved = self:_requireResolve(from.filename, moduleName, resolveOptions) -- if -- Boolean.toJSBoolean((function() @@ -2367,11 +2387,11 @@ end -- end -- return resolved -- end --- resolve.paths = function(_self: any, moduleName: string) +-- resolve.paths = function(_self: any, moduleName: ModuleScript) -- return self:_requireResolvePaths(from.filename, moduleName) -- end -- local moduleRequire = if Boolean.toJSBoolean(if typeof(options) == "table" then options.isInternalModule else nil) --- then function(moduleName: string) +-- then function(moduleName: ModuleScript) -- return self:requireInternalModule(from.filename, moduleName) -- end -- else self.requireModuleOrMock:bind(self, from.filename) :: NodeRequire @@ -2404,6 +2424,7 @@ end -- return moduleRequire -- end -- ROBLOX deviation END + -- ROBLOX deviation START: using ModuleScript instead of Config_Path -- function Runtime_private:_createJestObjectFor(from: Config_Path): Jest function Runtime_private:_createJestObjectFor(from: ModuleScript): Jest @@ -2423,7 +2444,6 @@ function Runtime_private:_createJestObjectFor(from: ModuleScript): Jest -- end -- ROBLOX deviation END -- ROBLOX deviation START: using ModuleScript instead of string - -- local function unmock(moduleName: string) local function unmock(moduleName: ModuleScript) -- ROBLOX deviation END -- ROBLOX deviation START: using module script instead of string moduleName @@ -2435,7 +2455,7 @@ function Runtime_private:_createJestObjectFor(from: ModuleScript): Jest return jestObject end -- ROBLOX deviation START: not implemented yet - -- local function deepUnmock(moduleName: string) + -- local function deepUnmock(moduleName: ModuleScript) -- local moduleID = -- self._resolver:getModuleID(self._virtualMocks, from, moduleName, { conditions = self.cjsConditions }) -- self._explicitShouldMock:set(moduleID, false) @@ -2460,7 +2480,6 @@ function Runtime_private:_createJestObjectFor(from: ModuleScript): Jest return jestObject end -- ROBLOX deviation START: using ModuleScript instead of string and predefine function - -- local function setMockFactory(moduleName: string, mockFactory: () -> unknown, options: { virtual: boolean? }?) function setMockFactory(moduleName: ModuleScript, mockFactory: () -> unknown, options: { virtual: boolean? }?) -- ROBLOX deviation END self:setMock(from, moduleName, mockFactory, options) @@ -2538,13 +2557,17 @@ function Runtime_private:_createJestObjectFor(from: ModuleScript): Jest return jestObject end -- ROBLOX deviation START: no built-in bind support in Luau - local fn = function(implementation: any) - return self._moduleMocker:fn(implementation) + local fn = function(...) + return self._moduleMocker:fn(...) + end + local spyOn = function(...) + return self._moduleMocker:spyOn(...) end -- local fn = self._moduleMocker.fn:bind(self._moduleMocker) - -- ROBLOX deviation END -- local spyOn = self._moduleMocker.spyOn:bind(self._moduleMocker) + -- ROBLOX deviation END + -- ROBLOX deviation START: not implemented yet -- local ref = if typeof(self._moduleMocker.mocked) == "table" then self._moduleMocker.mocked.bind else nil -- local ref = if ref ~= nil then ref(self._moduleMocker) else nil @@ -2595,7 +2618,7 @@ function Runtime_private:_createJestObjectFor(from: ModuleScript): Jest return _getFakeTimers():clearAllTimers() end, -- ROBLOX TODO START: not implemented yet - -- createMockFromModule = function(moduleName: string) + -- createMockFromModule = function(moduleName: ModuleScript) -- return self:_generateMock(from, moduleName) -- end, -- deepUnmock = deepUnmock, @@ -2608,7 +2631,7 @@ function Runtime_private:_createJestObjectFor(from: ModuleScript): Jest -- ROBLOX TODO END fn = fn, -- ROBLOX TODO START: not implemented yet - -- genMockFromModule = function(moduleName: string) + -- genMockFromModule = function(moduleName: ModuleScript) -- return self:_generateMock(from, moduleName) -- end, -- ROBLOX TODO END @@ -2623,6 +2646,8 @@ function Runtime_private:_createJestObjectFor(from: ModuleScript): Jest getTimerCount = function() return _getFakeTimers():getTimerCount() end, + -- ROBLOX deviation: mocking globals + globalEnv = self._globalMocker.envObject, isMockFunction = self._moduleMocker.isMockFunction, isolateModules = isolateModules, mock = mock, @@ -2631,7 +2656,12 @@ function Runtime_private:_createJestObjectFor(from: ModuleScript): Jest -- ROBLOX deviation END -- ROBLOX deviation START: issue roblox/js-to-lua #686 - no built-in bind support in Luau -- requireActual = self.requireActual:bind(self, from), - requireActual = function(moduleName) + requireActual = function(moduleName: ModuleScript | string) + if typeof(moduleName) == "string" then + -- Disabling this at the surface level of the API until we have + -- deeper support in Jest. + error("Require-by-string is not enabled for use inside Jest at this time.") + end return self:requireActual(from, moduleName) end, -- ROBLOX deviation END @@ -2666,7 +2696,6 @@ function Runtime_private:_createJestObjectFor(from: ModuleScript): Jest jestTimers = _getFakeTimers(), -- ROBLOX deviation END -- ROBLOX deviation START: using ModuleScript instead of string moduleName & virtual mocks not supported - -- setMock = function(moduleName: string, mock: unknown) setMock = function(moduleName: ModuleScript, mock: unknown) -- ROBLOX deviation END return setMockFactory(moduleName, function() @@ -2683,12 +2712,12 @@ function Runtime_private:_createJestObjectFor(from: ModuleScript): Jest end, -- ROBLOX TODO START: not implemented yet -- setTimeout = setTimeout, - -- spyOn = spyOn, - -- ROBOX TODO END + -- ROBLOX TODO END + spyOn = spyOn, unmock = unmock, -- ROBLOX TODO START: not implemented yet -- unstable_mockModule = mockModule, - -- ROBOX TODO END + -- ROBLOX TODO END useFakeTimers = useFakeTimers, useRealTimers = useRealTimers, } diff --git a/src/jest-snapshot-serializer-raw/README.md b/src/jest-snapshot-serializer-raw/README.md index bb1766f3..36c6242d 100644 --- a/src/jest-snapshot-serializer-raw/README.md +++ b/src/jest-snapshot-serializer-raw/README.md @@ -1,21 +1,8 @@ # jest-util -Status: :heavy_check_mark: Ported - -Source: https://github.com/ikatyang/jest-snapshot-serializer-raw/tree/v1.2.0 - -Version: v1.2.0 +Upstream: https://github.com/ikatyang/jest-snapshot-serializer-raw/tree/v1.2.0 --- ### :pencil2: Notes -### :x: Excluded - - -### :package: [Dependencies](https://github.com/ikatyang/jest-snapshot-serializer-raw/blob/v1.2.0/package.json) - -| Package | Version | Status | Notes | -| ------- | ------- | ------ | ----- | - - diff --git a/src/jest-snapshot/README.md b/src/jest-snapshot/README.md index 48748c28..ed377196 100644 --- a/src/jest-snapshot/README.md +++ b/src/jest-snapshot/README.md @@ -1,10 +1,9 @@ # jest-snapshot -Status: :hammer: In Progress +Upstream: https://github.com/facebook/jest/tree/v27.4.7/packages/jest-snapshot -Source: https://github.com/facebook/jest/tree/v27.4.7/packages/jest-snapshot +This package implements snapshot testing capabilities of Jest. You can find its documentation in the [Jest documentation](https://roblox.github.io/jest-roblox-internal). -Version: v27.4.7 --- diff --git a/src/jest-snapshot/src/PrettyFormat.lua b/src/jest-snapshot/src/PrettyFormat.lua index f7d54f9f..34840c04 100644 --- a/src/jest-snapshot/src/PrettyFormat.lua +++ b/src/jest-snapshot/src/PrettyFormat.lua @@ -42,6 +42,7 @@ export type PrettyFormatOptions = { printBasicPrototype: boolean?, printInstanceDefaults: boolean?, printFunctionName: boolean?, + redactStackTracesInStrings: boolean?, theme: ThemeReceived?, } diff --git a/src/jest-snapshot/src/SnapshotResolver.lua b/src/jest-snapshot/src/SnapshotResolver.lua index f9f1666e..54dfcaa0 100644 --- a/src/jest-snapshot/src/SnapshotResolver.lua +++ b/src/jest-snapshot/src/SnapshotResolver.lua @@ -29,17 +29,10 @@ type Config_ProjectConfig = typesModule.Config_ProjectConfig -- ROBLOX deviation END -- ROBLOX deviation START: additinal dependencies -local utils = require("./utils") -local getParent = utils.robloxGetParent - -local function getCoreScriptSyncService() - local success, result = pcall(function() - return game:GetService("CoreScriptSyncService") - end) - - return success and result or nil -end -local CoreScriptSyncService = nil +local RobloxShared = require("@pkg/@jsdotlua/jest-roblox-shared") +local getParent = RobloxShared.getParent +local getDataModelService = RobloxShared.getDataModelService +local CoreScriptSyncService = getDataModelService("CoreScriptSyncService") -- ROBLOX deviation END -- ROBLOX deviation START: predefine functions @@ -137,9 +130,6 @@ function createDefaultSnapshotResolver(): SnapshotResolver return snapshotPath end, getPath = function() - if CoreScriptSyncService == nil then - CoreScriptSyncService = getCoreScriptSyncService() or false - end if not CoreScriptSyncService then error( Error( diff --git a/src/jest-snapshot/src/__tests__/__snapshots__/snapshot.roblox.spec.snap.lua b/src/jest-snapshot/src/__tests__/__snapshots__/snapshot.roblox.spec.snap.lua index 82e8045b..5cb6386d 100644 --- a/src/jest-snapshot/src/__tests__/__snapshots__/snapshot.roblox.spec.snap.lua +++ b/src/jest-snapshot/src/__tests__/__snapshots__/snapshot.roblox.spec.snap.lua @@ -1,7 +1,5 @@ -- Jest Roblox Snapshot v1, http://roblox.github.io/jest-roblox-internal/snapshot-testing - local exports = {} - exports[ [=[custom snapshot matchers: toMatchTrimmedSnapshot 1]=] ] = [=[ "extra long"]=] diff --git a/src/jest-snapshot/src/init.lua b/src/jest-snapshot/src/init.lua index 6f5ac3f3..5b28438b 100644 --- a/src/jest-snapshot/src/init.lua +++ b/src/jest-snapshot/src/init.lua @@ -13,6 +13,11 @@ local Error = LuauPolyfill.Error local instanceof = LuauPolyfill.instanceof local AssertionError = LuauPolyfill.AssertionError +-- ROBLOX deviation START: additional dependencies +local RobloxShared = require("@pkg/@jsdotlua/jest-roblox-shared") +local cleanLoadStringStack = RobloxShared.cleanLoadStringStack +-- ROBLOX deviation END + local getType = require("@pkg/@jsdotlua/jest-get-type").getType -- ROBLOX TODO: ADO-1633 fix Jest Types imports @@ -407,6 +412,9 @@ function _toThrowErrorMatchingSnapshot(config: types.MatchSnapshotConfig, fromPr error_ = tostring(error_) end + -- ROBLOX deviation: if loadstring is used, format the loadstring stacktrace to look like a path + error_ = cleanLoadStringStack(error_) + return _toMatchSnapshot({ context = context, hint = hint, diff --git a/src/jest-snapshot/src/utils.lua b/src/jest-snapshot/src/utils.lua index 9681667b..8e62abc2 100644 --- a/src/jest-snapshot/src/utils.lua +++ b/src/jest-snapshot/src/utils.lua @@ -10,14 +10,9 @@ -- corresponds to the functions needed by the other translated files. We plan -- on filling the rest of utils out as we continue with the jest-snapshot file. -local function getFileSystemService() - local success, result = pcall(function() - return game:GetService("FileSystemService") - end) - - return success and result or nil -end -local FileSystemService = nil +local RobloxShared = require("@pkg/@jsdotlua/jest-roblox-shared") +local getDataModelService = RobloxShared.getDataModelService +local FileSystemService = getDataModelService("FileSystemService") local LuauPolyfill = require("@pkg/@jsdotlua/luau-polyfill") local Array = LuauPolyfill.Array @@ -221,26 +216,8 @@ local function printBacktickString(str: string): string return "[=[\n" .. str .. "]=]" end -local function ensureDirectoryExists(filePath: string) - -- ROBLOX deviation: gets path of parent directory, GetScriptFilePath can only be called on ModuleScripts - local pathComponents = filePath:split("/") - pathComponents = table.pack(table.unpack(pathComponents, 1, #pathComponents - 1)) - local path = table.concat(pathComponents, "/") - local ok, err = pcall(function() - if not FileSystemService:Exists(path) then - FileSystemService:CreateDirectories(path) - end - end) - - if not ok and err:find("Error%(13%): Access Denied%. Path is outside of sandbox%.") then - error( - Error.new( - "Provided path is invalid: you likely need to provide a different argument to --fs.readwrite.\n" - .. "You may need to pass in `--fs.readwrite=$PWD`" - ) - ) - end -end +-- ROBLOX deviation: moved to RobloxShared +local ensureDirectoryExists = RobloxShared.ensureDirectoryExists function normalizeNewLines(string_: string) string_ = string.gsub(string_, "\r\n", "\n") @@ -282,9 +259,6 @@ local function saveSnapshotFile(snapshotData: SnapshotData, snapshotPath: Config end table.insert(snapshots, "return exports") - if FileSystemService == nil then - FileSystemService = getFileSystemService() or false - end -- ROBLOX deviation: error when FileSystemService doesn't exist if not FileSystemService then error(Error("Attempting to save snapshots in an environment where FileSystemService is inaccessible.")) @@ -341,27 +315,6 @@ function deepMerge(target: any, source: any): any return target end --- ROBLOX deviation: added to handle file paths in snapshot/State -local function robloxGetParent(path: string, level_: number?): string - local level = if level_ then level_ else 0 - - local isUnixPath = string.sub(path, 1, 1) == "/" - local t = {} - - for p in string.gmatch(path, "[^\\/][^\\/]*") do - table.insert(t, p) - end - if level > 0 then - t = { table.unpack(t, 1, #t - level) } - end - - if isUnixPath then - return "/" .. table.concat(t, "/") - end - - return table.concat(t, "\\") -end - return { testNameToKey = testNameToKey, keyToTestName = keyToTestName, @@ -374,6 +327,4 @@ return { escapeBacktickString = escapeBacktickString, saveSnapshotFile = saveSnapshotFile, deepMerge = deepMerge, - -- ROBLOX deviation: not in upstream - robloxGetParent = robloxGetParent, } diff --git a/src/jest-test-result/README.md b/src/jest-test-result/README.md index 0324cf9f..66e5dfce 100644 --- a/src/jest-test-result/README.md +++ b/src/jest-test-result/README.md @@ -1,19 +1,7 @@ # jest-test-result -Status: :heavy_check_mark: Ported - -Source: https://github.com/facebook/jest/tree/v27.4.7/packages/jest-test-result - -Version: v27.4.7 +Upstream: https://github.com/facebook/jest/tree/v27.4.7/packages/jest-test-result --- ### :pencil2: Notes - -### :x: Excluded -``` -``` - -### :package: [Dependencies](https://github.com/facebook/jest/blob/v27.4.7/packages/jest-test-result/package.json) -| Package | Version | Status | Notes | -| ------------------ | ------- | ------------------------- | ------------------------------------------------ | diff --git a/src/jest-types/README.md b/src/jest-types/README.md index 1daa4248..9e869ae6 100644 --- a/src/jest-types/README.md +++ b/src/jest-types/README.md @@ -1,21 +1,7 @@ # jest-types -Status: :hammer: In Progress - -Source: https://github.com/facebook/jest/tree/v27.4.7/packages/jest-types - -Version: v27.4.7 +Upstream: https://github.com/facebook/jest/tree/v27.4.7/packages/jest-types --- ### :pencil2: Notes - -### :x: Excluded -``` -__typechecks__/* -``` - -### :package: [Dependencies](https://github.com/facebook/jest/blob/v27.4.7/packages/jest-types/package.json) -| Package | Version | Status | Notes | -| ---------- | ------- | ----------------- | ---------------------------------------- | -| `@mlh-tsd` | 4.2.4 | :x: Will not port | Uses TypeScript compiler to assert types | diff --git a/src/jest-types/src/Config.lua b/src/jest-types/src/Config.lua index 51b73509..0478d41e 100644 --- a/src/jest-types/src/Config.lua +++ b/src/jest-types/src/Config.lua @@ -213,6 +213,8 @@ export type DefaultOptions = { -- notify: boolean, -- notifyMode: NotifyMode, -- ROBLOX deviation END + -- ROBLOX deviation: inject alike types + oldFunctionSpying: boolean, passWithNoTests: boolean, -- ROBLOX deviation START: not supported -- prettierPath: string, @@ -430,6 +432,8 @@ export type InitialOptions = { -- onlyFailures: boolean?, -- ROBLOX deviation END outputFile: Path?, + -- ROBLOX deviation: inject alike types + oldFunctionSpying: boolean?, passWithNoTests: boolean?, --[[* * @deprecated Use `transformIgnorePatterns` options instead. @@ -665,6 +669,8 @@ export type ProjectConfig = { -- modulePaths: Array?, -- prettierPath: string, -- ROBLOX deviation END + -- ROBLOX deviation: inject alike types + oldFunctionSpying: boolean, resetMocks: boolean, resetModules: boolean, -- ROBLOX deviation START: not supported diff --git a/src/jest-util/README.md b/src/jest-util/README.md index 59fb5844..ea0c1456 100644 --- a/src/jest-util/README.md +++ b/src/jest-util/README.md @@ -1,26 +1,7 @@ # jest-util -Status: :hammer: In Progress - -Source: https://github.com/facebook/jest/tree/v27.4.7/packages/jest-util - -Version: v27.4.7 +Upstream: https://github.com/facebook/jest/tree/v27.4.7/packages/jest-util --- ### :pencil2: Notes - -### :x: Excluded - - -### :package: [Dependencies](https://github.com/facebook/jest/blob/v27.4.7/packages/jest-util/package.json) - -| Package | Version | Status | Notes | -|---------------|---------|---------------------------|-------| -| `@jest/types` | 27.4.2 | :heavy_check_mark: Ported | | -| `@types/node` | * | :x: Will not port | | -| `chalk` | 4.0.0 | :heavy_check_mark: Ported | | -| `ci-info` | 3.2.0 | :x: Will not port | | -| `graceful-fs` | 4.2.4 | :hammer: In Progress | | -| `picomatch` | 2.2.3 | :hammer: In Progress | | - diff --git a/src/jest-util/src/getFileSystemService.lua b/src/jest-util/src/getFileSystemService.lua index dda482ff..cb06df81 100644 --- a/src/jest-util/src/getFileSystemService.lua +++ b/src/jest-util/src/getFileSystemService.lua @@ -17,9 +17,12 @@ local LuauPolyfill = require("@pkg/@jsdotlua/luau-polyfill") local Error = LuauPolyfill.Error +local RobloxShared = require("@pkg/@jsdotlua/jest-roblox-shared") +local getDataModelService = RobloxShared.getDataModelService + local function getFileSystemService() local success, result = pcall(function() - return _G.__MOCK_FILE_SYSTEM__ or game:GetService("FileSystemService") + return _G.__MOCK_FILE_SYSTEM__ or getDataModelService("FileSystemService") end) if not success then diff --git a/src/jest-validate/README.md b/src/jest-validate/README.md index eb9f8a04..31d656fd 100644 --- a/src/jest-validate/README.md +++ b/src/jest-validate/README.md @@ -1,21 +1,8 @@ # jest-validate -Status: :hammer: In Progress - -Source: https://github.com/facebook/jest/tree/v27.4.7/packages/jest-validate - -Version: v27.4.7 +Upstream: https://github.com/facebook/jest/tree/v27.4.7/packages/jest-validate --- ### :pencil2: Notes -### :x: Excluded - - -### :package: [Dependencies](https://github.com/facebook/jest/blob/v27.4.7/packages/jest-validate/package.json) - -| Package | Version | Status | Notes | -| ------- | ------- | ------ | ----- | - - diff --git a/src/jest/README.md b/src/jest/README.md index f805e9b6..f79d3e67 100644 --- a/src/jest/README.md +++ b/src/jest/README.md @@ -1,19 +1,9 @@ # jest -Status: :hammer: In Progress +Upstream: https://github.com/facebook/jest/tree/v27.4.7/packages/jest -Source: https://github.com/facebook/jest/tree/v27.4.7/packages/jest - -Version: v27.4.7 +This package exports the `Jest` object used in Jest. The main entrypoint to the test framework should be `JestGlobals`. You can find its documentation in the [Jest documentation](https://roblox.github.io/jest-roblox-internal). --- ### :pencil2: Notes - -### :x: Excluded -``` -``` - -### :package: [Dependencies](https://github.com/facebook/jest/blob/v27.4.7/packages/jest/package.json) -| Package | Version | Status | Notes | -| ------- | ------- | ------ | ----- | diff --git a/src/pretty-format/README.md b/src/pretty-format/README.md index 85ca4dd0..9ec0a8ec 100644 --- a/src/pretty-format/README.md +++ b/src/pretty-format/README.md @@ -1,10 +1,10 @@ # pretty-format -Status: :hammer: In Progress +Upstream: https://github.com/facebook/jest/tree/v27.4.7/packages/pretty-format -Source: https://github.com/facebook/jest/tree/v27.4.7/packages/pretty-format - -Version: v27.4.7 +Stringify any Luau value +* Supports Luau builtins and Roblox Instances. +* Can be extended with user defined plugins. --- @@ -20,17 +20,3 @@ Version: v27.4.7 * `getConfig` is rewritten to avoid ternary operators. loop is rewritten with a `for` loop instead of an `iterator.next()`. * `Collections.lua` deviates from upstream substantially since Lua only has tables. We only have two functions: `printTableEntries` for formatting key, value pairs and `printListItems` for formatting arrays. - -### :x: Excluded -``` -perf -src/plugins/ConvertAnsi.ts -src/__tests__/ConvertAnsi.test.ts -``` - -### :package: [Dependencies](https://github.com/facebook/jest/blob/v27.4.7/packages/pretty-format/package.json) -| Package | Version | Status | Notes | -| ------------- | ------------------- | ------------------------- | ---------------------------------------- | -| `ansi-regex` | 5.0.1 | :x: Will not port | Console output styling is not a priority | -| `ansi-styles` | 5.0.0 | :x: Will not port | See above | -| `react-is` | see roact-alignment | :heavy_check_mark: Ported | Imported from roact-alignment | diff --git a/src/pretty-format/src/Types.lua b/src/pretty-format/src/Types.lua index 2d74d058..d948609b 100644 --- a/src/pretty-format/src/Types.lua +++ b/src/pretty-format/src/Types.lua @@ -48,6 +48,8 @@ export type Options = { printBasicPrototype: boolean, printInstanceDefaults: boolean, printFunctionName: boolean, + -- ROBLOX deviation: stable stacktrace snapshots + redactStackTracesInStrings: boolean?, theme: Theme, } @@ -64,6 +66,8 @@ export type PrettyFormatOptions = { printBasicPrototype: boolean?, printInstanceDefaults: boolean?, printFunctionName: boolean?, + -- ROBLOX deviation: stable stacktrace snapshots + redactStackTracesInStrings: boolean?, theme: ThemeReceived?, } @@ -84,6 +88,8 @@ export type Config = { printBasicPrototype: boolean, printInstanceDefaults: boolean, printFunctionName: boolean, + -- ROBLOX deviation: stable stacktrace snapshots + redactStackTracesInStrings: boolean, spacingInner: string, spacingOuter: string, } diff --git a/src/pretty-format/src/__tests__/RedactStackTraces.roblox.spec.lua b/src/pretty-format/src/__tests__/RedactStackTraces.roblox.spec.lua new file mode 100644 index 00000000..602e8c29 --- /dev/null +++ b/src/pretty-format/src/__tests__/RedactStackTraces.roblox.spec.lua @@ -0,0 +1,109 @@ +--[[ + * Copyright (c) Roblox Corporation. All rights reserved. + * Licensed under the MIT License (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://opensource.org/licenses/MIT + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. +]] +-- ROBLOX NOTE: no upstream + +local PrettyFormat = require("..") +local prettyFormat = PrettyFormat.default +local RedactStackTraces = PrettyFormat.plugins.RedactStackTraces + +local LuauPolyfill = require("@pkg/@jsdotlua/luau-polyfill") +local Error = LuauPolyfill.Error + +local JestGlobals = require("@pkg/@jsdotlua/jest-globals") +local expect = JestGlobals.expect +local describe = JestGlobals.describe +local it = JestGlobals.it + +local function pretty(val: any) + return prettyFormat(val, { + plugins = { RedactStackTraces }, + redactStackTracesInStrings = true, + }) +end + +local function prettyNoStr(val: any) + return prettyFormat(val, { + plugins = { RedactStackTraces }, + redactStackTracesInStrings = false, + }) +end + +local function prettyNoPlugin(val: any) + return prettyFormat(val, { + plugins = {}, + }) +end + +local LUA_ERROR = "LoadedCode.JestRoblox._Workspace.JestSnapshot.JestSnapshot.__tests__.snapshot.roblox.spec:35: " + .. "Every journey in the debugger starts with a single step" + +local JS_STACK = "Error: If at first you don't succeed, pray you're in a protected call\n" + .. "LoadedCode.JestRoblox._Workspace.JestSnapshot.JestSnapshot.__tests__.snapshot.roblox.spec:35 function foo\n" + .. "LoadedCode.JestRoblox._Workspace.JestSnapshot.JestSnapshot.__tests__.snapshot.roblox.spec:35\n" + .. "LoadedCode.JestRoblox._Workspace.JestSnapshot.JestSnapshot.__tests__.snapshot.roblox.spec:35" + +local JS_ERROR = Error.new(JS_STACK) +JS_ERROR.stack = JS_STACK + +local UNRELATED = "this.is.not.related: 25, unrelated_message:64" + +describe("Lua errors (strings)", function() + it("does not keep the file path", function() + expect(pretty(LUA_ERROR)).never.toContain( + "LoadedCode.JestRoblox._Workspace.JestSnapshot.JestSnapshot.__tests__.snapshot.roblox.spec" + ) + end) + + it("does not keep the line number", function() + expect(pretty(LUA_ERROR)).never.toContain("35") + end) + + it("keeps the message intact", function() + expect(pretty(LUA_ERROR)).toContain("Every journey in the debugger starts with a single step") + end) + + it("doesn't touch unrelated messages", function() + expect(pretty(UNRELATED)).toEqual(prettyNoPlugin(UNRELATED)) + end) + + it("respects the configuration setting to disable string redaction", function() + expect(prettyNoStr(LUA_ERROR)).toEqual(prettyNoPlugin(LUA_ERROR)) + end) +end) + +describe("JS errors (non-strings)", function() + it("does not keep the file path", function() + expect(pretty(JS_ERROR)).never.toContain( + "LoadedCode.JestRoblox._Workspace.JestSnapshot.JestSnapshot.__tests__.snapshot.roblox.spec" + ) + expect(prettyNoStr(JS_ERROR)).never.toContain( + "LoadedCode.JestRoblox._Workspace.JestSnapshot.JestSnapshot.__tests__.snapshot.roblox.spec" + ) + end) + + it("does not keep the line number", function() + expect(pretty(JS_ERROR)).never.toContain("35") + expect(prettyNoStr(JS_ERROR)).never.toContain("35") + end) + + it("keeps the message intact", function() + expect(pretty(JS_ERROR)).toContain("If at first you don't succeed, pray you're in a protected call") + expect(prettyNoStr(JS_ERROR)).toContain("If at first you don't succeed, pray you're in a protected call") + end) +end) + +it("doesn't touch unrelated messages", function() + expect(prettyNoStr(UNRELATED)).toEqual(prettyNoPlugin(UNRELATED)) +end) diff --git a/src/pretty-format/src/__tests__/RobloxInstance.roblox.spec.lua b/src/pretty-format/src/__tests__/RobloxInstance.roblox.spec.lua index c032dc76..9b5c0b26 100644 --- a/src/pretty-format/src/__tests__/RobloxInstance.roblox.spec.lua +++ b/src/pretty-format/src/__tests__/RobloxInstance.roblox.spec.lua @@ -47,7 +47,8 @@ describe("Instance", function() end) it("serializes Folder", function() - expect(prettyFormatResult(script.Parent.Parent)).toMatchSnapshot() + local stableFolder = (script.Parent :: Instance):FindFirstChild("dont_touch_im_used_in_snapshots") + expect(prettyFormatResult(stableFolder)).toMatchSnapshot() end) it("serializes Instances in table", function() @@ -202,13 +203,9 @@ describe("config.printInstanceDefaults", function() it("serializes modified values", function() created.Name = "ModifiedTextLabel" - created.Text = "not default" + created.TextColor3 = Color3.new(1, 0, 0) expect(prettyFormatResult(created)).toEqual( - "TextLabel {\n" - .. ' "ContentText": "not default",\n' - .. ' "Name": "ModifiedTextLabel",\n' - .. ' "Text": "not default",\n' - .. "}" + "TextLabel {\n" .. ' "Name": "ModifiedTextLabel",\n' .. ' "TextColor3": Color3(1, 0, 0),\n' .. "}" ) end) diff --git a/src/pretty-format/src/__tests__/__snapshots__/RobloxInstance.roblox.spec.snap.lua b/src/pretty-format/src/__tests__/__snapshots__/RobloxInstance.roblox.spec.snap.lua index 589302b7..b8f142af 100644 --- a/src/pretty-format/src/__tests__/__snapshots__/RobloxInstance.roblox.spec.snap.lua +++ b/src/pretty-format/src/__tests__/__snapshots__/RobloxInstance.roblox.spec.snap.lua @@ -1,8 +1,5 @@ --- ROBLOX NOTE: no upstream -- Jest Roblox Snapshot v1, http://roblox.github.io/jest-roblox-internal/snapshot-testing - local exports = {} - exports[ [=[Instance collapses circular references in properties 1]=] ] = [=[ "ScrollingFrame { @@ -59,148 +56,40 @@ exports[ [=[Instance collapses circular references in properties 1]=] ] = [=[ exports[ [=[Instance serializes Folder 1]=] ] = [=[ -"ModuleScript { +"Folder { \"Archivable\": true, - \"ClassName\": \"ModuleScript\", - \"Name\": \"src\", - \"Parent\": \"pretty-format\" [Folder], - \"Collections\": ModuleScript { + \"ClassName\": \"Folder\", + \"Name\": \"dont_touch_im_used_in_snapshots\", + \"Parent\": \"__tests__\" [Folder], + \"array\": ModuleScript { \"Archivable\": true, \"ClassName\": \"ModuleScript\", - \"Name\": \"Collections\", - \"Parent\": \"src\" [ModuleScript], + \"Name\": \"array\", + \"Parent\": \"dont_touch_im_used_in_snapshots\" [Folder], }, - \"Types\": ModuleScript { + \"format\": ModuleScript { \"Archivable\": true, \"ClassName\": \"ModuleScript\", - \"Name\": \"Types\", - \"Parent\": \"src\" [ModuleScript], + \"Name\": \"format\", + \"Parent\": \"dont_touch_im_used_in_snapshots\" [Folder], }, - \"__tests__\": Folder { + \"interpolation\": ModuleScript { \"Archivable\": true, - \"ClassName\": \"Folder\", - \"Name\": \"__tests__\", - \"Parent\": \"src\" [ModuleScript], - \"AsymmetricMatcher.spec\": ModuleScript { - \"Archivable\": true, - \"ClassName\": \"ModuleScript\", - \"Name\": \"AsymmetricMatcher.spec\", - \"Parent\": \"__tests__\" [Folder], - }, - \"ConvertAnsi.spec\": ModuleScript { - \"Archivable\": true, - \"ClassName\": \"ModuleScript\", - \"Name\": \"ConvertAnsi.spec\", - \"Parent\": \"__tests__\" [Folder], - }, - \"ReactElement.spec\": ModuleScript { - \"Archivable\": true, - \"ClassName\": \"ModuleScript\", - \"Name\": \"ReactElement.spec\", - \"Parent\": \"__tests__\" [Folder], - }, - \"RobloxInstance.roblox.spec\": ModuleScript { - \"Archivable\": true, - \"ClassName\": \"ModuleScript\", - \"Name\": \"RobloxInstance.roblox.spec\", - \"Parent\": \"__tests__\" [Folder], - }, - \"__snapshots__\": Folder { - \"Archivable\": true, - \"ClassName\": \"Folder\", - \"Name\": \"__snapshots__\", - \"Parent\": \"__tests__\" [Folder], - \"RobloxInstance.roblox.spec.snap\": ModuleScript { - \"Archivable\": true, - \"ClassName\": \"ModuleScript\", - \"Name\": \"RobloxInstance.roblox.spec.snap\", - \"Parent\": \"__snapshots__\" [Folder], - }, - \"react.spec.snap\": ModuleScript { - \"Archivable\": true, - \"ClassName\": \"ModuleScript\", - \"Name\": \"react.spec.snap\", - \"Parent\": \"__snapshots__\" [Folder], - }, - }, - \"prettyFormat.spec\": ModuleScript { - \"Archivable\": true, - \"ClassName\": \"ModuleScript\", - \"Name\": \"prettyFormat.spec\", - \"Parent\": \"__tests__\" [Folder], - }, - \"react.spec\": ModuleScript { - \"Archivable\": true, - \"ClassName\": \"ModuleScript\", - \"Name\": \"react.spec\", - \"Parent\": \"__tests__\" [Folder], - }, - \"roblox.spec\": ModuleScript { - \"Archivable\": true, - \"ClassName\": \"ModuleScript\", - \"Name\": \"roblox.spec\", - \"Parent\": \"__tests__\" [Folder], - }, - \"setPrettyPrint\": ModuleScript { - \"Archivable\": true, - \"ClassName\": \"ModuleScript\", - \"Name\": \"setPrettyPrint\", - \"Parent\": \"__tests__\" [Folder], - }, + \"ClassName\": \"ModuleScript\", + \"Name\": \"interpolation\", + \"Parent\": \"dont_touch_im_used_in_snapshots\" [Folder], }, - \"plugins\": Folder { + \"please_dont_touch_this\": ModuleScript { \"Archivable\": true, - \"ClassName\": \"Folder\", - \"Name\": \"plugins\", - \"Parent\": \"src\" [ModuleScript], - \"AsymmetricMatcher\": ModuleScript { - \"Archivable\": true, - \"ClassName\": \"ModuleScript\", - \"Name\": \"AsymmetricMatcher\", - \"Parent\": \"plugins\" [Folder], - }, - \"ConvertAnsi\": ModuleScript { - \"Archivable\": true, - \"ClassName\": \"ModuleScript\", - \"Name\": \"ConvertAnsi\", - \"Parent\": \"plugins\" [Folder], - }, - \"ReactElement\": ModuleScript { - \"Archivable\": true, - \"ClassName\": \"ModuleScript\", - \"Name\": \"ReactElement\", - \"Parent\": \"plugins\" [Folder], - }, - \"ReactTestComponent\": ModuleScript { - \"Archivable\": true, - \"ClassName\": \"ModuleScript\", - \"Name\": \"ReactTestComponent\", - \"Parent\": \"plugins\" [Folder], - }, - \"RobloxInstance\": ModuleScript { - \"Archivable\": true, - \"ClassName\": \"ModuleScript\", - \"Name\": \"RobloxInstance\", - \"Parent\": \"plugins\" [Folder], - }, - \"lib\": Folder { - \"Archivable\": true, - \"ClassName\": \"Folder\", - \"Name\": \"lib\", - \"Parent\": \"plugins\" [Folder], - \"escapeHTML\": ModuleScript { - \"Archivable\": true, - \"ClassName\": \"ModuleScript\", - \"Name\": \"escapeHTML\", - \"Parent\": \"lib\" [Folder], - }, - \"markup\": ModuleScript { - \"Archivable\": true, - \"ClassName\": \"ModuleScript\", - \"Name\": \"markup\", - \"Parent\": \"lib\" [Folder], - }, - }, + \"ClassName\": \"ModuleScript\", + \"Name\": \"please_dont_touch_this\", + \"Parent\": \"dont_touch_im_used_in_snapshots\" [Folder], + }, + \"template\": ModuleScript { + \"Archivable\": true, + \"ClassName\": \"ModuleScript\", + \"Name\": \"template\", + \"Parent\": \"dont_touch_im_used_in_snapshots\" [Folder], }, }" ]=] diff --git a/src/pretty-format/src/__tests__/dont_touch_im_used_in_snapshots/array.lua b/src/pretty-format/src/__tests__/dont_touch_im_used_in_snapshots/array.lua new file mode 100644 index 00000000..15e53c4a --- /dev/null +++ b/src/pretty-format/src/__tests__/dont_touch_im_used_in_snapshots/array.lua @@ -0,0 +1 @@ +return nil diff --git a/src/pretty-format/src/__tests__/dont_touch_im_used_in_snapshots/format.lua b/src/pretty-format/src/__tests__/dont_touch_im_used_in_snapshots/format.lua new file mode 100644 index 00000000..15e53c4a --- /dev/null +++ b/src/pretty-format/src/__tests__/dont_touch_im_used_in_snapshots/format.lua @@ -0,0 +1 @@ +return nil diff --git a/src/pretty-format/src/__tests__/dont_touch_im_used_in_snapshots/interpolation.lua b/src/pretty-format/src/__tests__/dont_touch_im_used_in_snapshots/interpolation.lua new file mode 100644 index 00000000..15e53c4a --- /dev/null +++ b/src/pretty-format/src/__tests__/dont_touch_im_used_in_snapshots/interpolation.lua @@ -0,0 +1 @@ +return nil diff --git a/src/pretty-format/src/__tests__/dont_touch_im_used_in_snapshots/please_dont_touch_this.lua b/src/pretty-format/src/__tests__/dont_touch_im_used_in_snapshots/please_dont_touch_this.lua new file mode 100644 index 00000000..2e633b93 --- /dev/null +++ b/src/pretty-format/src/__tests__/dont_touch_im_used_in_snapshots/please_dont_touch_this.lua @@ -0,0 +1,12 @@ +--[[ + I copied this folder into here for testing purposes. + + Specifically, one of the unit tests tries to serialise this folder, and + saves the result to a snapshot. If the snapshot changes, the test fails. + + So if you touch this folder, you'll break that test. + + Apart from that, nothing in here does anything meaningful. +]] + +return nil diff --git a/src/pretty-format/src/__tests__/dont_touch_im_used_in_snapshots/template.lua b/src/pretty-format/src/__tests__/dont_touch_im_used_in_snapshots/template.lua new file mode 100644 index 00000000..15e53c4a --- /dev/null +++ b/src/pretty-format/src/__tests__/dont_touch_im_used_in_snapshots/template.lua @@ -0,0 +1 @@ +return nil diff --git a/src/pretty-format/src/__tests__/roblox.spec.lua b/src/pretty-format/src/__tests__/roblox.spec.lua index 7b4d85d0..eb0de837 100644 --- a/src/pretty-format/src/__tests__/roblox.spec.lua +++ b/src/pretty-format/src/__tests__/roblox.spec.lua @@ -65,3 +65,9 @@ describe("bad plugin", function() expect(result.message).toMatch("attempt to index number with 'foo'") end) end) + +it("errors on nonexistent plugin reads", function() + expect(function() + local _ = require(script.Parent).plugins.thisIsNotAPlugin + end).toThrowError() +end) diff --git a/src/pretty-format/src/init.lua b/src/pretty-format/src/init.lua index 1ad3e2ae..71317da3 100644 --- a/src/pretty-format/src/init.lua +++ b/src/pretty-format/src/init.lua @@ -24,6 +24,7 @@ local ConvertAnsi = require("./plugins/ConvertAnsi") local RobloxInstance = require("./plugins/RobloxInstance") local ReactElement = require("./plugins/ReactElement") local ReactTestComponent = require("./plugins/ReactTestComponent") +local RedactStackTraces = require("./plugins/RedactStackTraces") local JestGetType = require("@pkg/@jsdotlua/jest-get-type") local getType = JestGetType.getType @@ -351,6 +352,8 @@ local DEFAULT_OPTIONS = { -- ROBLOX deviation: option to omit default Roblox Instance values printInstanceDefaults = true, printFunctionName = true, + -- ROBLOX deviation: stable stacktrace snapshots + redactStackTracesInStrings = false, -- ROBLOX deviation: color formatting omitted theme = nil, } @@ -425,6 +428,8 @@ local function getConfig(options: OptionsReceived?): Config else true, -- ROBLOX deviation: option to omit default Roblox Instance values printInstanceDefaults = getOption(options, "printInstanceDefaults"), + -- ROBLOX deviation: stable stack traces in snapshots + redactStackTracesInStrings = getOption(options, "redactStackTracesInStrings"), printFunctionName = getOption(options, "printFunctionName"), spacingInner = getSpacingInner(options), spacingOuter = getSpacingOuter(options), @@ -473,8 +478,18 @@ local plugins = { ReactTestComponent = ReactTestComponent, -- ROBLOX deviation: Roblox Instance matchers RobloxInstance = RobloxInstance, + -- ROBLOX deviation: stable stacktrace snapshots + RedactStackTraces = RedactStackTraces, } +-- ROBLOX deviation start: protect against bad reads +setmetatable(plugins, { + __index = function(self, key) + error(Error.new("Can't find pretty-format plugin: " .. key)) + end, +}) +-- ROBLOX deviation end + return { format = format, default = format, diff --git a/src/pretty-format/src/plugins/RedactStackTraces.lua b/src/pretty-format/src/plugins/RedactStackTraces.lua new file mode 100644 index 00000000..f90ca646 --- /dev/null +++ b/src/pretty-format/src/plugins/RedactStackTraces.lua @@ -0,0 +1,64 @@ +--[[ + * Copyright (c) Roblox Corporation. All rights reserved. + * Licensed under the MIT License (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://opensource.org/licenses/MIT + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. +]] +-- ROBLOX NOTE: no upstream + +local JestGetType = require("@pkg/@jsdotlua/jest-get-type") +local getType = JestGetType.getType + +local redactStackTrace = require("@pkg/@jsdotlua/jest-roblox-shared").redactStackTrace + +local Types = require("../Types") +type Config = Types.Config +type Refs = Types.Refs +type Printer = Types.Printer + +local RedactStackTraces = {} + +function RedactStackTraces.serialize( + val: any, + config: Config, + indentation: string, + depth: number, + refs: Refs, + printer: Printer +): string + depth = depth + 1 + local ty = getType(val) + if ty == "string" then + local interiorConfig = table.clone(config) + interiorConfig.plugins = table.clone(interiorConfig.plugins) + table.remove(interiorConfig.plugins, table.find(interiorConfig.plugins, RedactStackTraces)) + local pretty = printer(val, interiorConfig, indentation, depth, refs) + if config.redactStackTracesInStrings then + pretty = redactStackTrace(pretty) :: string + end + return pretty + elseif ty == "error" then + local interiorConfig = table.clone(config) + interiorConfig.plugins = table.clone(interiorConfig.plugins) + table.remove(interiorConfig.plugins, table.find(interiorConfig.plugins, RedactStackTraces)) + local pretty = printer(val, interiorConfig, indentation, depth, refs) + return redactStackTrace(pretty) :: string + else + error("not supported") + end +end + +function RedactStackTraces.test(val: any): boolean + local ty = getType(val) + return ty == "error" or ty == "string" +end + +return RedactStackTraces diff --git a/src/pretty-format/src/plugins/RobloxInstance.lua b/src/pretty-format/src/plugins/RobloxInstance.lua index dad810a9..d90b96de 100644 --- a/src/pretty-format/src/plugins/RobloxInstance.lua +++ b/src/pretty-format/src/plugins/RobloxInstance.lua @@ -20,12 +20,11 @@ local JestGetType = require("@pkg/@jsdotlua/jest-get-type") local getType = JestGetType.getType local LuauPolyfill = require("@pkg/@jsdotlua/luau-polyfill") +local Object = LuauPolyfill.Object local Array = LuauPolyfill.Array local instanceof = LuauPolyfill.instanceof local RobloxInstance = require("@pkg/@jsdotlua/jest-roblox-shared").RobloxInstance -local getRobloxProperties = RobloxInstance.getRobloxProperties -local getRobloxDefaults = RobloxInstance.getRobloxDefaults local InstanceSubset = RobloxInstance.InstanceSubset local printTableEntries = require("../Collections").printTableEntries @@ -45,44 +44,44 @@ local function printInstance( ): string local result = "" - local children = val:GetChildren() - table.sort(children, function(a, b) + local printChildrenList = val:GetChildren() + table.sort(printChildrenList, function(a, b) return a.Name < b.Name end) - local props - local defaults - if config.printInstanceDefaults then - props = getRobloxProperties(val.ClassName) - else - defaults, props = getRobloxDefaults(val.ClassName) - end + local propertiesMap = RobloxInstance.listProps(val) + local printPropsList = Object.keys(propertiesMap) if not config.printInstanceDefaults then - props = Array.filter(props, function(propertyName) - return defaults[propertyName] ~= val[propertyName] + local defaultsMap = RobloxInstance.listDefaultProps(val.ClassName) + printPropsList = Array.filter(printPropsList, function(name) + return propertiesMap[name] ~= defaultsMap[name] end) end + table.sort(printPropsList) + + local willPrintProps = #printPropsList > 0 + local willPrintChildren = #printChildrenList > 0 - if #props > 0 or #children > 0 then + if willPrintProps or willPrintChildren then result = result .. config.spacingOuter local indentationNext = indentation .. config.indent -- print properties of Instance - for i, propertyName in ipairs(props) do - local name = printer(propertyName, config, indentationNext, depth, refs) - local value = val[propertyName] + for propOrder, propName in ipairs(printPropsList) do + local propValue = propertiesMap[propName] + if propValue == Object.None then + propValue = nil + end -- collapses output for Instance values to avoid loops - if getType(value) == "Instance" then - value = printer(value, config, indentationNext, math.huge, refs) - else - value = printer(value, config, indentationNext, depth, refs) - end + local valueDepth = if getType(propValue) == "Instance" then math.huge else depth + local printName = printer(propName, config, indentationNext, depth, refs) + local printValue = printer(propValue, config, indentationNext, valueDepth, refs) - result = string.format("%s%s%s: %s", result, indentationNext, name, value) + result = string.format("%s%s%s: %s", result, indentationNext, printName, printValue) - if i < #props or #children > 0 then + if propOrder ~= #printPropsList or willPrintChildren then result = result .. "," .. config.spacingInner elseif not config.min then result = result .. "," @@ -90,13 +89,13 @@ local function printInstance( end -- recursively print children of Instance - for i, v in ipairs(children) do - local name = printer(v.Name, config, indentationNext, depth, refs) - local value = printer(v, config, indentationNext, depth, refs) + for childOrder, child in ipairs(printChildrenList) do + local printName = printer(child.Name, config, indentationNext, depth, refs) + local printValue = printer(child, config, indentationNext, depth, refs) - result = string.format("%s%s%s: %s", result, indentationNext, name, value) + result = string.format("%s%s%s: %s", result, indentationNext, printName, printValue) - if i < #children then + if childOrder ~= #printChildrenList then result = result .. "," .. config.spacingInner elseif not config.min then result = result .. "," diff --git a/src/test-utils/README.md b/src/test-utils/README.md index 0eb1370c..665ca221 100644 --- a/src/test-utils/README.md +++ b/src/test-utils/README.md @@ -1,19 +1,7 @@ # test-utils -Status: :hammer: In Progress - -Source: - -Version: +Upstream: https://github.com/jestjs/jest/tree/v28.0.0/packages/test-utils --- ### :pencil2: Notes - -### :x: Excluded -``` -``` - -### :package: [Dependencies]() -| Package | Version | Status | Notes | -| ------- | ------- | ------ | ----- | diff --git a/src/test-utils/src/config.lua b/src/test-utils/src/config.lua index ef43e49d..cc5f6ede 100644 --- a/src/test-utils/src/config.lua +++ b/src/test-utils/src/config.lua @@ -105,6 +105,8 @@ local DEFAULT_PROJECT_CONFIG: Config_ProjectConfig = { moduleNameMapper = {}, modulePathIgnorePatterns = {}, modulePaths = {}, + -- ROBLOX deviation: inject alike types + oldFunctionSpying = true, prettierPath = "prettier", resetMocks = false, resetModules = false, diff --git a/src/throat/README.md b/src/throat/README.md index 09b05b17..7141dc3c 100644 --- a/src/throat/README.md +++ b/src/throat/README.md @@ -1,19 +1,7 @@ # throat -Status: :heavy_check_mark: Ported - -Source: https://github.com/ForbesLindesay/throat/tree/6.0.1 - -Version: 6.0.1 +Upstream: https://github.com/ForbesLindesay/throat/tree/6.0.1 --- ### :pencil2: Notes - -### :x: Excluded -``` -``` - -### :package: [Dependencies]() -| Package | Version | Status | Notes | -| ------- | ------- | ------ | ----- | diff --git a/yarn.lock b/yarn.lock index 892a5f14..dbb0831d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -252,6 +252,7 @@ __metadata: dependencies: "@jsdotlua/jest-fake-timers": "workspace:^" "@jsdotlua/jest-mock": "workspace:^" + "@jsdotlua/jest-mock-genv": "workspace:^" "@jsdotlua/jest-types": "workspace:^" "@jsdotlua/luau-polyfill": "npm:^1.2.6" npmluau: "npm:^0.1.1" @@ -340,11 +341,36 @@ __metadata: languageName: unknown linkType: soft +"@jsdotlua/jest-mock-genv@workspace:^, @jsdotlua/jest-mock-genv@workspace:src/jest-mock-genv": + version: 0.0.0-use.local + resolution: "@jsdotlua/jest-mock-genv@workspace:src/jest-mock-genv" + dependencies: + "@jsdotlua/jest-globals": "workspace:^" + "@jsdotlua/luau-polyfill": "npm:^1.2.6" + npmluau: "npm:^0.1.1" + languageName: unknown + linkType: soft + +"@jsdotlua/jest-mock-rbx@workspace:src/jest-mock-rbx": + version: 0.0.0-use.local + resolution: "@jsdotlua/jest-mock-rbx@workspace:src/jest-mock-rbx" + dependencies: + "@jsdotlua/jest-config": "workspace:^" + "@jsdotlua/jest-globals": "workspace:^" + "@jsdotlua/jest-types": "workspace:^" + "@jsdotlua/luau-polyfill": "npm:^1.2.6" + npmluau: "npm:^0.1.1" + languageName: unknown + linkType: soft + "@jsdotlua/jest-mock@workspace:^, @jsdotlua/jest-mock@workspace:src/jest-mock": version: 0.0.0-use.local resolution: "@jsdotlua/jest-mock@workspace:src/jest-mock" dependencies: + "@jsdotlua/jest-config": "workspace:^" "@jsdotlua/jest-globals": "workspace:^" + "@jsdotlua/jest-mock-genv": "workspace:^" + "@jsdotlua/jest-types": "workspace:^" "@jsdotlua/luau-polyfill": "npm:^1.2.6" npmluau: "npm:^0.1.1" languageName: unknown @@ -355,6 +381,7 @@ __metadata: resolution: "@jsdotlua/jest-reporters@workspace:src/jest-reporters" dependencies: "@jsdotlua/chalk": "npm:^0.2.1" + "@jsdotlua/jest-config": "workspace:^" "@jsdotlua/jest-console": "workspace:^" "@jsdotlua/jest-globals": "workspace:^" "@jsdotlua/jest-message-util": "workspace:^" @@ -375,6 +402,7 @@ __metadata: version: 0.0.0-use.local resolution: "@jsdotlua/jest-roblox-shared@workspace:src/jest-roblox-shared" dependencies: + "@jsdotlua/jest-config": "workspace:^" "@jsdotlua/jest-get-type": "workspace:^" "@jsdotlua/jest-globals": "workspace:^" "@jsdotlua/jest-mock": "workspace:^" @@ -413,9 +441,11 @@ __metadata: dependencies: "@jsdotlua/emittery": "workspace:^" "@jsdotlua/expect": "workspace:^" + "@jsdotlua/jest-config": "workspace:^" "@jsdotlua/jest-fake-timers": "workspace:^" "@jsdotlua/jest-globals": "workspace:^" "@jsdotlua/jest-mock": "workspace:^" + "@jsdotlua/jest-mock-genv": "workspace:^" "@jsdotlua/jest-snapshot": "workspace:^" "@jsdotlua/jest-types": "workspace:^" "@jsdotlua/luau-polyfill": "npm:^1.2.6"