@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.
@@ -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.4",
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
@@ -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>;