@flamework-experimental/core 2.0.0-alpha.3 → 2.0.0-alpha.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +11 -6
- package/docs/README.md +63 -0
- package/docs/guide/01-getting-started.md +334 -0
- package/docs/guide/02-modules.md +254 -0
- package/docs/guide/03-providers.md +423 -0
- package/docs/guide/04-lifecycle-events.md +423 -0
- package/docs/guide/05-components.md +793 -0
- package/docs/guide/06-networking.md +614 -0
- package/docs/guide/07-macros.md +332 -0
- package/docs/guide/08-plugins.md +203 -0
- package/docs/guide/09-project-structure.md +392 -0
- package/docs/guide/10-migrating-from-v1.md +573 -0
- package/docs/guide/11-scopes.md +165 -0
- package/docs/guide/12-testing.md +342 -0
- package/flamework.build +1 -1
- package/out/index.d.ts +1 -0
- package/out/init.luau +1 -0
- package/out/module/module.luau +1 -1
- package/out/module/moduleBuilder.luau +1 -1
- package/out/utility/getClassesInPath.d.ts +27 -1
- package/out/utility/getClassesInPath.luau +101 -19
- package/out/utility/pathRoot.d.ts +20 -1
- package/out/utility/pathRoot.luau +80 -4
- package/package.json +14 -7
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
# 11. Scopes
|
|
2
|
+
|
|
3
|
+
A **scope** is a name that a build is compiled with. You can tell providers, components,
|
|
4
|
+
registrations, plugin inclusions and whole modules to exist only in builds with certain scopes
|
|
5
|
+
active, or never in builds with others. That way a repository can hold test scenarios, debug tooling
|
|
6
|
+
and stand-ins for production code, and none of it registers in a build that did not ask for it.
|
|
7
|
+
|
|
8
|
+
## Naming the active scopes
|
|
9
|
+
|
|
10
|
+
The active scopes are `scopes.active` in `flamework.config.json`. The list is meant to come from the
|
|
11
|
+
environment:
|
|
12
|
+
|
|
13
|
+
```jsonc
|
|
14
|
+
// flamework.config.json
|
|
15
|
+
{ "scopes": { "active": "${FLAMEWORK_SCOPES:-}" } }
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
```ini
|
|
19
|
+
# .env.local
|
|
20
|
+
FLAMEWORK_SCOPES=components,collections
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
The variable is split on commas. An empty value activates nothing, and `*` activates every scope.
|
|
24
|
+
[Values from the environment](09-project-structure.md#values-from-the-environment) explains how the
|
|
25
|
+
file reads `.env`. A running `rbxtsc -w` reports a change but keeps the scopes it started with, so
|
|
26
|
+
restart the watcher to switch scopes (see [watching](09-project-structure.md#watching)).
|
|
27
|
+
|
|
28
|
+
At runtime, `Flamework.activeScopes()` returns the list as configured.
|
|
29
|
+
`Flamework.isScopeActive(name)` tells you whether one scope is active, and is always true when `*`
|
|
30
|
+
is.
|
|
31
|
+
|
|
32
|
+
## Conditions
|
|
33
|
+
|
|
34
|
+
A condition has two lists:
|
|
35
|
+
|
|
36
|
+
| Field | Holds when |
|
|
37
|
+
|---|---|
|
|
38
|
+
| `activeIn` | at least one of the names is active, or the list is empty |
|
|
39
|
+
| `inactiveIn` | none of the names is active |
|
|
40
|
+
|
|
41
|
+
You can set conditions at four levels: the module, a registration, a plugin inclusion and the class
|
|
42
|
+
itself. They combine by AND: a class is registered only when every condition that applies to it
|
|
43
|
+
holds.
|
|
44
|
+
|
|
45
|
+
```ts
|
|
46
|
+
// The module: everything it registers is subject to this.
|
|
47
|
+
Flamework.createModule()
|
|
48
|
+
// A registration: everything under the folder gets this on top.
|
|
49
|
+
.registerProviders("src/server/Testing/components", { activeIn: ["components"] })
|
|
50
|
+
// A plugin inclusion: skipped entirely, hooks and all, unless it holds.
|
|
51
|
+
.includePlugin(ComponentPlugin.fromPath("src/server/Testing/components"), { activeIn: ["components"] })
|
|
52
|
+
.ignite({ activeIn: ["components"] });
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
```ts
|
|
56
|
+
// The class itself.
|
|
57
|
+
@Provider({ activeIn: ["components.streaming"] })
|
|
58
|
+
export class StreamingProbe {
|
|
59
|
+
constructor(private readonly data: DataService) {}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
@Component({ tag: "TestRig", activeIn: ["components"] })
|
|
63
|
+
export class TestRig extends BaseComponent<{}, Model> {}
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
`StreamingProbe`, inside the module above, is registered only when both `components` and
|
|
67
|
+
`components.streaming` are active. A class can narrow the condition of whatever registered it, but
|
|
68
|
+
never widen it. A class inside a `components` module cannot exist without `components`. If it
|
|
69
|
+
should, put it in another module or registration.
|
|
70
|
+
|
|
71
|
+
A module whose own condition does not hold still ignites. It holds no providers and no components,
|
|
72
|
+
but its plugins are set up. `Dependency<T>(module)` raises an error for anything it would have had.
|
|
73
|
+
|
|
74
|
+
A path or glob registration whose own condition does not hold skips its folder entirely. It does not
|
|
75
|
+
look the folder up or load anything in it, so the folder does not have to be in the place. This
|
|
76
|
+
applies to `registerProviders`, `registerProvidersGlob`, `ComponentPlugin.fromPath` and `fromGlob`,
|
|
77
|
+
`registerComponents` and `registerComponentsGlob`, and a plugin's own `registerProviders` and
|
|
78
|
+
`registerProvidersGlob`.
|
|
79
|
+
|
|
80
|
+
The other levels work differently:
|
|
81
|
+
|
|
82
|
+
- The module's condition and the class's condition are checked at ignition, after the folder has
|
|
83
|
+
loaded.
|
|
84
|
+
- A plugin inclusion whose condition does not hold skips the plugin's setup. So the registrations a
|
|
85
|
+
plugin makes in its setup never run, and their folders are not looked up.
|
|
86
|
+
|
|
87
|
+
`ComponentPlugin.fromPath` and `fromGlob` look their folder up when you call them, before
|
|
88
|
+
`includePlugin` sees its condition. Put the condition on `fromPath` itself.
|
|
89
|
+
|
|
90
|
+
In the example above, `registerProviders` loads its folder only when `components` is active.
|
|
91
|
+
`ComponentPlugin.fromPath` loads its folder in every build.
|
|
92
|
+
|
|
93
|
+
## What being left out means
|
|
94
|
+
|
|
95
|
+
A class whose conditions do not hold is **not registered**. It is not constructed, it receives no
|
|
96
|
+
lifecycle events, and `resolveDependency` does not find it. A component that is left out is never
|
|
97
|
+
attached to a tagged instance. Anything active that depends on a left-out class fails at ignition,
|
|
98
|
+
with the reason:
|
|
99
|
+
|
|
100
|
+
```
|
|
101
|
+
module 'Game' could not resolve dependency 'server/Testing/Probe@Probe': it is registered but
|
|
102
|
+
inactive (activeIn [components]; active scopes [])
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
`getComponent` on a left-out component says the same. A lazy provider that was left out reports it
|
|
106
|
+
the first time something resolves it.
|
|
107
|
+
|
|
108
|
+
A class under a folder whose registration was left out is different. That folder was never loaded,
|
|
109
|
+
so the class was never registered at all. The error names the registration instead:
|
|
110
|
+
|
|
111
|
+
```
|
|
112
|
+
module 'Game' could not resolve dependency 'server/Testing/Probe@Probe': 'Probe'
|
|
113
|
+
(ServerScriptService.TS.Testing.Probe) is under registerProviders("src/server/Testing"), which is
|
|
114
|
+
left out by its scope (activeIn [components]; active scopes []): nothing under it is registered.
|
|
115
|
+
Change the build's scopes so that the condition holds, or do not depend on it in this build
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
This needs the class to be loaded, for example by a file that imports it as a value. If nothing has
|
|
119
|
+
loaded it, the error lists every registration that the module left out this way, since the class
|
|
120
|
+
may be under any of them. `getComponent` does the same for the registrations of the module's component plugins.
|
|
121
|
+
|
|
122
|
+
## Standing in for production code
|
|
123
|
+
|
|
124
|
+
Two registrations may share an id if their conditions keep at most one of them in any one build.
|
|
125
|
+
That is how a fake takes a real provider's place, in the same module as the real one, so everything
|
|
126
|
+
that injects it gets the fake:
|
|
127
|
+
|
|
128
|
+
```ts
|
|
129
|
+
Flamework.createModule()
|
|
130
|
+
.registerClassProvider(CollectionHandler, { inactiveIn: ["collections"] })
|
|
131
|
+
.registerProvider<CollectionHandler>({ type: "class", value: FakeCollectionHandler, activeIn: ["collections"] })
|
|
132
|
+
.ignite();
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
With `collections` active, the fake is registered under the real one's id, and the real one is not.
|
|
136
|
+
If both are kept in one build, ignition refuses it, as it does for any duplicate id.
|
|
137
|
+
|
|
138
|
+
Use `inactiveIn` on its own for production code that a test replaces or must not run alongside: a
|
|
139
|
+
real game loop, or a component whose tag a test rig reuses.
|
|
140
|
+
|
|
141
|
+
## Where scopes are decided
|
|
142
|
+
|
|
143
|
+
Every condition is checked against the scopes the build was compiled with. It is checked at
|
|
144
|
+
ignition. A path or glob registration's own condition is also checked when the registration is
|
|
145
|
+
made, so that a left-out folder is never loaded. Both checks give the same answer. After a change to
|
|
146
|
+
`.env`, rebuild (restart the watcher if one is running) and, in Studio, stop and play again. There
|
|
147
|
+
is no runtime override. The scopes a place runs with are part of the build, so a test scope cannot
|
|
148
|
+
be switched on in a published game.
|
|
149
|
+
|
|
150
|
+
## Caveats
|
|
151
|
+
|
|
152
|
+
- **Conditions narrow, never widen.** A class cannot opt out of its module's or registration's
|
|
153
|
+
condition. Move it instead.
|
|
154
|
+
- **A module's condition does not stop its plugins.** They are set up so that the module is
|
|
155
|
+
complete, and what they register is checked like everything else. To leave a plugin out, put the
|
|
156
|
+
condition on its inclusion.
|
|
157
|
+
- **`*` turns every `inactiveIn` off.** Running every scope at once runs every replacement at once.
|
|
158
|
+
Two tests that replace the same thing collide, and ignition says so.
|
|
159
|
+
- **Plugins with a registry of their own must ask.** The components plugin filters its classes with
|
|
160
|
+
`target.isActive(...)`. A plugin that keeps its own list of classes has to do the same, or its
|
|
161
|
+
classes ignore the module's condition. See [plugins](08-plugins.md#what-a-plugin-can-do).
|
|
162
|
+
|
|
163
|
+
---
|
|
164
|
+
|
|
165
|
+
Previous: [Migrating from v1](10-migrating-from-v1.md) · Next: [Testing in the place](12-testing.md)
|
|
@@ -0,0 +1,342 @@
|
|
|
1
|
+
# 12. Testing in the place
|
|
2
|
+
|
|
3
|
+
`@flamework-experimental/testing` runs tests inside a real place: in Studio, in a live server, or
|
|
4
|
+
in an Open Cloud task. Tests are plain functions grouped into **sections**. Providers define them,
|
|
5
|
+
so they get their dependencies injected like any other code. They are tied to a
|
|
6
|
+
[scope](11-scopes.md), so a build that did not ask for them does not register them.
|
|
7
|
+
|
|
8
|
+
```ts
|
|
9
|
+
// src/server/Tests/economy.ts
|
|
10
|
+
import { OnStart, Provider } from "@flamework-experimental/core";
|
|
11
|
+
import { defer, defineTests, expectEqual, test } from "@flamework-experimental/testing";
|
|
12
|
+
import { Shop } from "server/services/shop";
|
|
13
|
+
|
|
14
|
+
@Provider({ activeIn: ["testing"] })
|
|
15
|
+
export class EconomyTests implements OnStart {
|
|
16
|
+
constructor(private readonly shop: Shop) {}
|
|
17
|
+
|
|
18
|
+
onStart() {
|
|
19
|
+
defineTests("economy", () => {
|
|
20
|
+
test("buying deducts the price", () => {
|
|
21
|
+
const wallet = this.shop.open("test");
|
|
22
|
+
defer(() => this.shop.close("test"));
|
|
23
|
+
|
|
24
|
+
this.shop.buy("test", "sword");
|
|
25
|
+
expectEqual(wallet.balance, 90);
|
|
26
|
+
});
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
```ts
|
|
33
|
+
// src/server/main.ts
|
|
34
|
+
Flamework.createModule()
|
|
35
|
+
.registerProviders("src/server/services")
|
|
36
|
+
.registerProviders("src/server/Tests", { activeIn: ["testing"] })
|
|
37
|
+
.includePlugin(TestingPlugin)
|
|
38
|
+
.ignite();
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
```jsonc
|
|
42
|
+
// flamework.config.json
|
|
43
|
+
"scopes": { "active": "${FLAMEWORK_SCOPES:-}" }
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
In a build with `FLAMEWORK_SCOPES=testing` (the test script below sets it), the test providers
|
|
47
|
+
register like any other and define their sections as they start. The plugin creates
|
|
48
|
+
`Workspace.FlameworkTests` and waits. Nothing runs until something invokes it:
|
|
49
|
+
|
|
50
|
+
```lua
|
|
51
|
+
-- the Studio command bar, a debug UI, or `flamework-test` from a terminal
|
|
52
|
+
local result = workspace.FlameworkTests:Invoke() -- every section
|
|
53
|
+
local result = workspace.FlameworkTests:Invoke("economy") -- one section
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
Without the scope, the `Tests` folders are skipped entirely. A folder registration whose own
|
|
57
|
+
condition does not hold does not look the folder up or require anything in it. No test class is
|
|
58
|
+
loaded or constructed, the plugin does nothing, and no instance is made. One switch,
|
|
59
|
+
`FLAMEWORK_SCOPES`, turns on both the tests and the host that runs them. Keeping the files out of a
|
|
60
|
+
release place altogether is Rojo's job; see [shipping](#shipping).
|
|
61
|
+
|
|
62
|
+
## Setting up
|
|
63
|
+
|
|
64
|
+
Everything a project needs, in the order it is needed:
|
|
65
|
+
|
|
66
|
+
1. The package: `npm install @flamework-experimental/testing`. It brings the roblox-ts side and the
|
|
67
|
+
`flamework-test` CLI, nothing else. Map it in your Rojo project next to `core`
|
|
68
|
+
([Getting started › Rojo](01-getting-started.md#rojo)). The CLI runs on
|
|
69
|
+
[Bun](https://bun.sh), whatever installed it: npm's and pnpm's `flamework-test` command starts
|
|
70
|
+
`bun`, and without it on the `PATH` fails with `'"bun"' is not recognized`.
|
|
71
|
+
2. The switch: the `scopes` line above in `flamework.config.json`, and the scope in neither `.env`
|
|
72
|
+
nor `.env.local`. Every build reads both files: `.env` is committed
|
|
73
|
+
([Project structure › What to commit](09-project-structure.md#what-to-commit)), and `.env.local`
|
|
74
|
+
is read by every build on your machine, release builds included. A scope in either ships the
|
|
75
|
+
test host and its remote with those builds. Set the variable for the one build that needs it
|
|
76
|
+
instead, as the test script below does.
|
|
77
|
+
|
|
78
|
+
To have the tests in a place you sync with `rojo serve`, give the watcher the scope in its own
|
|
79
|
+
environment, and nothing else: a `watch:tests` script like the test script, which runs
|
|
80
|
+
`rbxtsc -w` with `FLAMEWORK_SCOPES: "testing"`. Once you stop it, `out/` still holds the test
|
|
81
|
+
host, so compile once without the scope (`npm run build`) before you build a place to ship.
|
|
82
|
+
3. A `Tests` folder per realm, registered under the scope, and `TestingPlugin` in each realm's
|
|
83
|
+
module. The server is shown above; the client is the same shape:
|
|
84
|
+
|
|
85
|
+
```ts
|
|
86
|
+
// src/client/runtime.client.ts
|
|
87
|
+
Flamework.createModule()
|
|
88
|
+
.registerProviders("src/client/controllers")
|
|
89
|
+
.registerProviders("src/client/Tests", { activeIn: ["testing"] })
|
|
90
|
+
.registerProviders("src/shared/Tests", { activeIn: ["testing"] }) // sections both realms run
|
|
91
|
+
.includePlugin(TestingPlugin)
|
|
92
|
+
.ignite();
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
A shared folder registered by both modules gives sections that run in both realms, one copy in
|
|
96
|
+
each. The component specs of this repository's test place,
|
|
97
|
+
[`tests/place`](https://github.com/Velover/ExperimentalFlameworkV2/blob/HEAD/tests/place/README.md), live in such a folder.
|
|
98
|
+
4. A script that compiles with the scope, builds the place, runs the tests, and then compiles again
|
|
99
|
+
without the scope, whatever happened, so that `out/` never keeps a build with the test host in
|
|
100
|
+
it. The rebuild sets the variable to nothing rather than leaving it out, so that a scope in
|
|
101
|
+
`.env.local` cannot come back through it. It exits with the tests' code:
|
|
102
|
+
|
|
103
|
+
```js
|
|
104
|
+
// scripts/test.mjs: run it as `npm test` or `bun run test`, which put node_modules/.bin on the
|
|
105
|
+
// PATH (`bun scripts/test.mjs` on its own finds no rbxtsc). Arguments go to flamework-test.
|
|
106
|
+
function run(command, env = process.env) {
|
|
107
|
+
try {
|
|
108
|
+
return Bun.spawnSync(command, { env, stdio: ["inherit", "inherit", "inherit"] }).exitCode ?? 1;
|
|
109
|
+
} catch {
|
|
110
|
+
console.error(`${command[0]} could not be started: is it installed?`);
|
|
111
|
+
return 127;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
let code = run(["rbxtsc"], { ...process.env, FLAMEWORK_SCOPES: "testing" });
|
|
116
|
+
if (code === 0) code = run(["rojo", "build", "-o", "test.rbxl"]);
|
|
117
|
+
if (code === 0) code = run(["flamework-test", "test", "test.rbxl", ...process.argv.slice(2)]);
|
|
118
|
+
|
|
119
|
+
console.log("rebuilding out/ without the testing scope...");
|
|
120
|
+
const rebuild = run(["rbxtsc"], { ...process.env, FLAMEWORK_SCOPES: "" });
|
|
121
|
+
process.exit(code !== 0 ? code : rebuild);
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
```jsonc
|
|
125
|
+
// package.json
|
|
126
|
+
"scripts": { "test": "bun scripts/test.mjs" }
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
`test.rbxl` has a name of its own, so the place a release is built into never holds the tests.
|
|
130
|
+
Ignore it with the other built places, `/*.rbxl` at the root. Besides the build, a run leaves:
|
|
131
|
+
- the places `flamework-test` makes beside the build (`test.patched.rbxl` with an original
|
|
132
|
+
place, `test.<project>.rbxl` under `--project`), which the same line covers;
|
|
133
|
+
- `build/version.json`, which `cloud publish` writes, and so `cloud test` and `test --cloud`;
|
|
134
|
+
- Studio's lock file beside a place it has open, `test.rbxl.lock`. `flamework-test` removes the
|
|
135
|
+
lock of a window it ends, but one left by a Studio closed any other way stays, so ignore
|
|
136
|
+
`*.rbxl.lock` too.
|
|
137
|
+
|
|
138
|
+
The files a patch needs while it runs go to a folder of the system's temp directory, one per
|
|
139
|
+
run, removed when the patch is done.
|
|
140
|
+
5. Roblox Studio with "MCP server" enabled in its Assistant settings, which is what lets the CLI
|
|
141
|
+
open a window, run the tests in it and close it again.
|
|
142
|
+
|
|
143
|
+
`npm test` (or `bun run test`) then prints one summary per realm. That is the whole setup for
|
|
144
|
+
Studio. Running in the cloud also needs an API key and a testing place; see
|
|
145
|
+
[Running the tests](https://github.com/Velover/ExperimentalFlameworkV2/blob/HEAD/docs/testing/place.md).
|
|
146
|
+
|
|
147
|
+
## Where tests live
|
|
148
|
+
|
|
149
|
+
`defineTests` is an ordinary function, so anything may call it. A provider's `onStart` is the
|
|
150
|
+
natural place, since by then every provider is constructed and every `onInit` has run. Put the scope
|
|
151
|
+
condition on the folder registration (`registerProviders("src/server/Tests", { activeIn: ["testing"] })`),
|
|
152
|
+
on the class (`@Provider({ activeIn: ["testing"] })`), or on both. These are the usual
|
|
153
|
+
[scope rules](11-scopes.md), nothing specific to testing.
|
|
154
|
+
|
|
155
|
+
Only the condition on the folder registration keeps the folder from loading in a build without the
|
|
156
|
+
scope. A release place that leaves the folder out needs that. A condition on the class is read after
|
|
157
|
+
its file has loaded.
|
|
158
|
+
|
|
159
|
+
Client tests have the same shape, in a client provider, with the plugin included in the client
|
|
160
|
+
module. Each realm has its own host.
|
|
161
|
+
|
|
162
|
+
## Sections and tests
|
|
163
|
+
|
|
164
|
+
`defineTests(name, body)` runs `body` at once. Inside it, `test(name, fn)` registers a test, and
|
|
165
|
+
`beforeEach` / `afterEach` register hooks. `name` may be `undefined`, which means the section
|
|
166
|
+
`"default"`. The same section name used by several providers is one section, so a feature's tests
|
|
167
|
+
can be spread over several files. Sections do not nest, and a name may not contain `/`.
|
|
168
|
+
|
|
169
|
+
The body receives a context with the section's `name` and the `module` that was igniting when the
|
|
170
|
+
section was defined:
|
|
171
|
+
|
|
172
|
+
```ts
|
|
173
|
+
defineTests("components", ({ module }) => {
|
|
174
|
+
test("finds the tagged part", () => {
|
|
175
|
+
const components = module!.resolveDependency<Components>();
|
|
176
|
+
// ...
|
|
177
|
+
});
|
|
178
|
+
});
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
Define sections before the first yield of `onStart`. `onStart` runs on its own thread, and the
|
|
182
|
+
module is only marked current until ignition finishes. A section defined after a `task.wait` still
|
|
183
|
+
registers, but without a module. Few tests need the module: a provider already has what it
|
|
184
|
+
injected.
|
|
185
|
+
|
|
186
|
+
A test may yield (`task.wait`, `WaitForChild`, a signal), and a Promise it returns is awaited.
|
|
187
|
+
Each test runs on its own thread, with a timeout of `testing.timeout` seconds (30 by default). A
|
|
188
|
+
test that runs over is cancelled and counted as failed, and the run moves on.
|
|
189
|
+
|
|
190
|
+
## Cleanup
|
|
191
|
+
|
|
192
|
+
Tests in a place leave things behind unless they clean up, and the next test would run against
|
|
193
|
+
whatever was left. There are three cleanup tools, and all of them run whether the test passed,
|
|
194
|
+
failed or timed out:
|
|
195
|
+
|
|
196
|
+
| Tool | Does |
|
|
197
|
+
|---|---|
|
|
198
|
+
| `defer(fn)` | Registers cleanup for the running test: a connection to disconnect, an instance to destroy, a state to restore. Runs in reverse order after the test. |
|
|
199
|
+
| `scratch()` | A Folder in Workspace for whatever the test builds, made on first use and destroyed with everything in it afterwards. |
|
|
200
|
+
| `afterEach(fn)` | A section-level hook, run after every test of the section. |
|
|
201
|
+
|
|
202
|
+
A cleanup that raises fails the test, since whatever it was meant to remove is still there.
|
|
203
|
+
|
|
204
|
+
## Assertions
|
|
205
|
+
|
|
206
|
+
`expectEqual`, `expectTrue`, `expectFalse`, `expectDefined`, `expectThrows`, `expectNoThrow`,
|
|
207
|
+
`expectArrayEqual`, `expectResolves`, `expectRejects` and `fail` raise a one-line message that
|
|
208
|
+
becomes the test's failure. `eventually(predicate, what?, timeout?)` checks `predicate` every frame,
|
|
209
|
+
for something the engine delivers later: a deferred signal, a replicated instance, a component built
|
|
210
|
+
on the next resumption. Any other assertion library works too: a test fails when its body raises an
|
|
211
|
+
error.
|
|
212
|
+
|
|
213
|
+
## Running
|
|
214
|
+
|
|
215
|
+
| From | How |
|
|
216
|
+
|---|---|
|
|
217
|
+
| A terminal, in Studio on this machine | `npm test`, the script in [Setting up](#setting-up): its `flamework-test test test.rbxl` opens the build in Studio, runs both realms, closes it; see [Running the tests](https://github.com/Velover/ExperimentalFlameworkV2/blob/HEAD/docs/testing/place.md) |
|
|
218
|
+
| A terminal, under another Rojo project | `npm test -- --project tests/deferred.project.json`: the same, in a place with that project's `$properties` set, `Workspace.SignalBehavior` and the streaming radii included; one run per `--project`, see [Workspace settings no script can change](https://github.com/Velover/ExperimentalFlameworkV2/blob/HEAD/docs/testing/place.md#workspace-settings-no-script-can-change) |
|
|
219
|
+
| A terminal, in the cloud | `npm test -- --cloud`: publishes to a testing place and runs the server's sections in a real server; needs `testing.entry`, see below |
|
|
220
|
+
| The realm's own code | `Testing.run(filter?)` and `Testing.list(filter?)` |
|
|
221
|
+
| Anything with the DataModel | `Workspace.FlameworkTests:Invoke(filter?, options?)` |
|
|
222
|
+
| A client, for the server's tests | `Testing.runOnServer(filter?)`, over `Workspace.FlameworkTestsServer` |
|
|
223
|
+
| Start-up | `"autoRun": true` in the config runs everything right after ignition |
|
|
224
|
+
|
|
225
|
+
A filter is nothing (every section), one section name, one `section/test` name, or a list of
|
|
226
|
+
those; `--sections a,b` on the command line. Passing `{ list = true }` as the options reports the
|
|
227
|
+
selection without running it. In one realm, an entry that names nothing there makes the run fail.
|
|
228
|
+
When `flamework-test` runs both realms, an entry only one realm has is fine: the other realm lists it
|
|
229
|
+
as `not among the client's sections: coin`, and the run fails only on an entry that no realm has
|
|
230
|
+
(`MISS matched nothing in any realm: coins`). So `--sections coin` runs a server-only section without
|
|
231
|
+
`--realm server`. The result is a plain table, the same whether it came back from an invoke, a
|
|
232
|
+
remote or `Testing.run`:
|
|
233
|
+
|
|
234
|
+
```lua
|
|
235
|
+
{ ok = true, realm = "server", passed = 12, failed = 0, durationMs = 340,
|
|
236
|
+
sections = { { name = "economy", passed = 12, failed = 0,
|
|
237
|
+
tests = { { name = "buying deducts the price", ok = true, durationMs = 3 }, ... } } },
|
|
238
|
+
unknown = {} } -- filter entries that named nothing in this realm; any makes ok false
|
|
239
|
+
```
|
|
240
|
+
|
|
241
|
+
Every test also prints one line, such as
|
|
242
|
+
`[FWTEST] server economy/buying deducts the price: PASS (3ms)`, and the run ends with a summary
|
|
243
|
+
line. So the Output window and a task's log show the same as the table.
|
|
244
|
+
|
|
245
|
+
A place made by `flamework-test` knows which Rojo project it was made under. `getProject()` returns
|
|
246
|
+
that project's name: `deferred` for `tests/deferred.project.json`, and `undefined` in a place opened
|
|
247
|
+
by hand. The result carries it as `project`. A test that only holds under one project's `Workspace`
|
|
248
|
+
settings (`SignalBehavior`, say) checks it and returns early under the others; see
|
|
249
|
+
[several projects, one suite](https://github.com/Velover/ExperimentalFlameworkV2/blob/HEAD/docs/testing/place.md#several-projects-one-suite).
|
|
250
|
+
|
|
251
|
+
A BindableFunction's callback is set per realm, so the one `Workspace.FlameworkTests` serves both.
|
|
252
|
+
A client with the plugin answers on it for its own tests, and reaches the server's tests through
|
|
253
|
+
`FlameworkTestsServer`. A second invoke while a run is in progress raises an error.
|
|
254
|
+
|
|
255
|
+
### Both realms in one session
|
|
256
|
+
|
|
257
|
+
`flamework-test test` runs the server's sections and then the client's, in the same play session.
|
|
258
|
+
So the client's tests run against a server whose own tests have already run, and they see whatever
|
|
259
|
+
those tests left on the wire.
|
|
260
|
+
|
|
261
|
+
One engine fact matters here. A RemoteEvent message fired at a client before it has connected
|
|
262
|
+
`OnClientEvent` is not dropped: the engine queues it and delivers it the first time anything
|
|
263
|
+
connects. Say a server test calls `predict` with the real player, through a handler that answers
|
|
264
|
+
with `fire(player, ...)`. That leaves a reply waiting, and the reply lands in the middle of the
|
|
265
|
+
client's tests as an answer nobody asked for.
|
|
266
|
+
|
|
267
|
+
So predict with a stand-in that is not a `Player` (`scratch()` will do), and have the answering
|
|
268
|
+
handler skip it:
|
|
269
|
+
|
|
270
|
+
```ts
|
|
271
|
+
function fromPlayer(player: Player) {
|
|
272
|
+
return typeIs(player, "Instance") && player.IsA("Player");
|
|
273
|
+
}
|
|
274
|
+
server.setScore.connect((player, score) => {
|
|
275
|
+
if (fromPlayer(player)) server.scoreChanged.fire(player, score);
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
test("accepts a message through its guards", () => {
|
|
279
|
+
server.setScore.predict(scratch() as unknown as Player, 5);
|
|
280
|
+
});
|
|
281
|
+
```
|
|
282
|
+
|
|
283
|
+
The `networking` sections of this repository's test place ([`tests/place`](https://github.com/Velover/ExperimentalFlameworkV2/blob/HEAD/tests/place/README.md))
|
|
284
|
+
are written this way.
|
|
285
|
+
|
|
286
|
+
## Configuration
|
|
287
|
+
|
|
288
|
+
```jsonc
|
|
289
|
+
"testing": {
|
|
290
|
+
"activeIn": ["testing"], // scopes under which the plugin attaches: any of them; the default
|
|
291
|
+
"inactiveIn": [], // scopes under which it never does
|
|
292
|
+
"enabled": true, // when set, overrides the two above in either direction
|
|
293
|
+
"autoRun": false, // run everything right after ignition
|
|
294
|
+
"timeout": 30, // seconds per test
|
|
295
|
+
"entry": "src/server/main" // cloud runs only: the ModuleScript exporting ignite()
|
|
296
|
+
}
|
|
297
|
+
```
|
|
298
|
+
|
|
299
|
+
`entry` exists for one reason. An Open Cloud task loads the place but runs none of its Scripts, so
|
|
300
|
+
nothing ignites the game there. The runner has to require a ModuleScript and call its `ignite()`
|
|
301
|
+
itself, and `entry` names that ModuleScript. In Studio, the place runs its own Scripts, and the
|
|
302
|
+
module is up before anything invokes the tests. So a project that only runs its tests locally never
|
|
303
|
+
sets `entry`. A cloud command refuses to publish without it.
|
|
304
|
+
|
|
305
|
+
`activeIn` and `inactiveIn` follow the same rules as everywhere else: `activeIn` needs at least one
|
|
306
|
+
of its names active, `inactiveIn` needs none of its names active, and an empty `activeIn` is no
|
|
307
|
+
constraint. `TestingPlugin` is the plugin with the file's settings. `createTestingPlugin({ ... })`
|
|
308
|
+
overrides them for one plugin, which is what a test harness of your own would use.
|
|
309
|
+
|
|
310
|
+
## Shipping
|
|
311
|
+
|
|
312
|
+
Never ship a build with tests on: the remote lets any client run the server's tests. Keep the
|
|
313
|
+
`testing` scope out of `.env` and `.env.local` altogether, since every build reads them; the test
|
|
314
|
+
script in [Setting up](#setting-up) sets the scope for its own build and compiles again with it set
|
|
315
|
+
to nothing afterwards, so `out/` is never left with the host in it. After a watcher that ran with
|
|
316
|
+
the scope, compile once without it before you build a place to ship.
|
|
317
|
+
|
|
318
|
+
Without the scope, the test files still compile and are still copied into the place. A `Tests`
|
|
319
|
+
folder registered under the scope is never loaded, though. To leave the files out of the place as
|
|
320
|
+
well, give the release build a Rojo project that ignores the folders:
|
|
321
|
+
|
|
322
|
+
```jsonc
|
|
323
|
+
// release.project.json, otherwise identical to default.project.json
|
|
324
|
+
"globIgnorePaths": ["**/package.json", "**/tsconfig.json", "**/Tests"]
|
|
325
|
+
```
|
|
326
|
+
|
|
327
|
+
`**/Tests` drops every folder with that name, at any depth (`**/Tests/**` would leave empty folders
|
|
328
|
+
behind).
|
|
329
|
+
|
|
330
|
+
Register such a folder by its own path only with the scope condition on the registration itself, as
|
|
331
|
+
in [Setting up](#setting-up): `registerProviders("src/server/Tests", { activeIn: ["testing"] })`.
|
|
332
|
+
The release build does not have the scope, so the folder is never looked up. Without that
|
|
333
|
+
condition on the registration, the folder is looked up in every build that runs it. That includes
|
|
334
|
+
`registerProviders("src/server/Tests")` with the condition only on the classes, and
|
|
335
|
+
`ComponentPlugin.fromPath` with the condition only on `includePlugin`. The transformer resolves the
|
|
336
|
+
path against the project file without regard to `globIgnorePaths`, so a registered folder that is
|
|
337
|
+
not in the place stalls ignition: the registration waits for it, and warns after five seconds that
|
|
338
|
+
it is `still waiting for its folder`.
|
|
339
|
+
|
|
340
|
+
---
|
|
341
|
+
|
|
342
|
+
Previous: [Scopes](11-scopes.md) · See also: [Running the tests](https://github.com/Velover/ExperimentalFlameworkV2/blob/HEAD/docs/testing/place.md), [Testing in Studio](https://github.com/Velover/ExperimentalFlameworkV2/blob/HEAD/docs/testing/studio.md)
|
package/flamework.build
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 1,
|
|
3
|
-
"flameworkVersion": "2.0.0-alpha.
|
|
3
|
+
"flameworkVersion": "2.0.0-alpha.5",
|
|
4
4
|
"identifiers": {
|
|
5
5
|
"@flamework-experimental/core:out/module/module@Module": "$:module/module@Module",
|
|
6
6
|
"@flamework-experimental/core:out/lifecycle/lifecyclePlugin@LifecycleProvider": "$:lifecycle/lifecyclePlugin@LifecycleProvider",
|
package/out/index.d.ts
CHANGED
|
@@ -4,6 +4,7 @@ export { Reflect } from "./reflect";
|
|
|
4
4
|
export { Provider } from "./provider";
|
|
5
5
|
export { Injectable } from "./injectable";
|
|
6
6
|
export { Dependency } from "./dependency";
|
|
7
|
+
export { requireModules } from "./utility/getClassesInPath";
|
|
7
8
|
export { Serialization } from "./serialization/types";
|
|
8
9
|
export type { ProviderDecoratorConfig } from "./provider";
|
|
9
10
|
export type { InjectableDecoratorConfig } from "./injectable";
|
package/out/init.luau
CHANGED
|
@@ -7,6 +7,7 @@ exports.Reflect = TS.import(script, script, "reflect").Reflect
|
|
|
7
7
|
exports.Provider = TS.import(script, script, "provider").Provider
|
|
8
8
|
exports.Injectable = TS.import(script, script, "injectable").Injectable
|
|
9
9
|
exports.Dependency = TS.import(script, script, "dependency").Dependency
|
|
10
|
+
exports.requireModules = TS.import(script, script, "utility", "getClassesInPath").requireModules
|
|
10
11
|
-- Modules
|
|
11
12
|
exports.ModuleDefinition = TS.import(script, script, "module", "moduleDefinition").ModuleDefinition
|
|
12
13
|
exports.ModuleBuilder = TS.import(script, script, "module", "moduleBuilder").ModuleBuilder
|
package/out/module/module.luau
CHANGED
|
@@ -1005,7 +1005,7 @@ local function createModuleInstantiation(state, options)
|
|
|
1005
1005
|
local _arg0 = resolved ~= nil
|
|
1006
1006
|
assert(_arg0)
|
|
1007
1007
|
if holdsCondition(registrationOptions) then
|
|
1008
|
-
registerProviderClasses(getClassesInPath(resolved), registrationOptions)
|
|
1008
|
+
registerProviderClasses(getClassesInPath(resolved, `registerProviders("{path}")`), registrationOptions)
|
|
1009
1009
|
else
|
|
1010
1010
|
local _arg0_1 = leftOutRegistration(`registerProviders("{path}")`, registrationOptions, {
|
|
1011
1011
|
path = resolved,
|
|
@@ -121,7 +121,7 @@ do
|
|
|
121
121
|
table.insert(_leftOut, _arg0)
|
|
122
122
|
return self
|
|
123
123
|
end
|
|
124
|
-
return self:registerProviderClasses(getClassesInPath(path), options)
|
|
124
|
+
return self:registerProviderClasses(getClassesInPath(path, `registerProviders("{_stringPath}")`), options)
|
|
125
125
|
end
|
|
126
126
|
function ModuleBuilder:registerProvidersGlob(_glob, options, glob)
|
|
127
127
|
local _arg0 = glob ~= nil
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { Modding } from "../modding";
|
|
1
2
|
/**
|
|
2
3
|
* Requires one ModuleScript through the roblox-ts runtime, as an `import` would, and returns what
|
|
3
4
|
* it exported. A module that fails to load raises with its full name and how long it took.
|
|
@@ -13,6 +14,28 @@ export declare function importModule(moduleScript: ModuleScript): defined | unde
|
|
|
13
14
|
* `resolveRbxPath` finds.
|
|
14
15
|
*/
|
|
15
16
|
export declare function requireModulesInPath(rbxPath: readonly string[]): Array<defined>;
|
|
17
|
+
/**
|
|
18
|
+
* Requires every ModuleScript at and under a source folder, in tree order, for what the modules do
|
|
19
|
+
* as they load, and returns what each exported, leaving out the ones that exported nothing. This is
|
|
20
|
+
* v1's `Flamework.addPaths` for a folder that holds no providers: modules that register themselves
|
|
21
|
+
* with a library, such as commands.
|
|
22
|
+
*
|
|
23
|
+
* The path is a source path, as a string literal: `requireModules("src/server/commands")`. The build
|
|
24
|
+
* turns it into the Rojo path the folder ends up at, as it does for `registerProviders`, so the
|
|
25
|
+
* folder has to be in your Rojo project.
|
|
26
|
+
*
|
|
27
|
+
* Each module is required through the roblox-ts module cache, so it runs once, however many times
|
|
28
|
+
* it is required. A folder inside a folder that `registerProviders` registers needs no call:
|
|
29
|
+
* registration already requires every ModuleScript under it.
|
|
30
|
+
*
|
|
31
|
+
* Raises when a module fails to load, and when the folder is not in the place. A missing folder is
|
|
32
|
+
* waited for five seconds, once the place has loaded, and the error names the part that is missing.
|
|
33
|
+
* A folder of the other realm's -- a server folder on a client, a client folder on the server --
|
|
34
|
+
* raises at once, saying so.
|
|
35
|
+
*
|
|
36
|
+
* @metadata macro
|
|
37
|
+
*/
|
|
38
|
+
export declare function requireModules<T extends string>(path: T, rbxPath?: Modding.Intrinsic<"path", [T], string[]>): Array<defined>;
|
|
16
39
|
/**
|
|
17
40
|
* Requires every ModuleScript at and under the specified Rojo path and returns every Flamework
|
|
18
41
|
* class they hold, each once: every class carrying its own identifier that a module defined at its
|
|
@@ -27,5 +50,8 @@ export declare function requireModulesInPath(rbxPath: readonly string[]): Array<
|
|
|
27
50
|
*
|
|
28
51
|
* A module that fails to load raises, as it did in v1: a class that silently fails to register
|
|
29
52
|
* would otherwise only show up later as an unresolvable dependency, far from the cause.
|
|
53
|
+
*
|
|
54
|
+
* The folder is waited for as {@link resolveRbxPath} waits: `caller`, the registration that gave the
|
|
55
|
+
* path (`registerProviders("src/server/services")`), is named in the warning a slow wait gets.
|
|
30
56
|
*/
|
|
31
|
-
export declare function getClassesInPath(rbxPath: readonly string[]): Array<object>;
|
|
57
|
+
export declare function getClassesInPath(rbxPath: readonly string[], caller?: string): Array<object>;
|