meocord 4.1.2 → 4.2.1
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/CHANGELOG.md +362 -0
- package/THIRD_PARTY_NOTICES.md +14 -13
- package/dist/cjs/_shared/{controller.decorator-BywwkU9O.cjs → controller.decorator-XKMlcUZz.cjs} +669 -185
- package/dist/cjs/_shared/{providers-BMMLp2SN.cjs → providers-BZgJyfZS.cjs} +218 -75
- package/dist/cjs/_shared/{sharded-cooldown-store-B65iaiAy.cjs → sharded-cooldown-store-CsJJyCZd.cjs} +10 -7
- package/dist/cjs/_shared/{startup-roots-lFc69AxC.cjs → startup-roots-CgfPQrNl.cjs} +374 -132
- package/dist/cjs/_shared/{translate-error-BnM8FbSH.cjs → translate-error-BUAR1WYG.cjs} +21 -7
- package/dist/cjs/common/index.cjs +4 -4
- package/dist/cjs/core/index.cjs +365 -310
- package/dist/cjs/decorator/index.cjs +85 -61
- package/dist/cjs/testing/index.cjs +2236 -496
- package/dist/cli.json +1 -1
- package/dist/esm/bin/app-template/README.md.template +3 -1
- package/dist/esm/bin/app-template/src/controllers/button/sample.button.controller.spec.ts.template +8 -4
- package/dist/esm/bin/app-template/src/controllers/button/sample.button.controller.ts.template +2 -2
- package/dist/esm/bin/app-template/src/guards/owner.guard.spec.ts.template +8 -4
- package/dist/esm/bin/app-template/src/guards/owner.guard.ts.template +1 -1
- package/dist/esm/bin/app-template/vitest.config.ts.template +1 -1
- package/dist/esm/bin/app-template/vitest.setup.ts.template +7 -3
- package/dist/esm/bin/generator.js +4 -5
- package/dist/esm/bin/helper/filter-generator.helper.js +4 -5
- package/dist/esm/bin/helper/guard-generator.helper.js +4 -5
- package/dist/esm/bin/helper/interceptor-generator.helper.js +4 -5
- package/dist/esm/bin/helper/observer-generator.helper.js +4 -5
- package/dist/esm/bin/helper/pipe-generator.helper.js +4 -5
- package/dist/esm/bin/helper/service-generator.helper.js +4 -5
- package/dist/esm/bin/meocord.js +84 -25
- package/dist/esm/build/load-config.pre-entry.js +17 -4
- package/dist/esm/build/rsbuild-config.js +16 -12
- package/dist/esm/build/typed-externals.js +36 -0
- package/dist/esm/common/cooldown-store.js +10 -2
- package/dist/esm/common/decorator.js +6 -6
- package/dist/esm/common/deprecation.js +2 -2
- package/dist/esm/common/errors.js +27 -2
- package/dist/esm/common/execution-context.js +1 -1
- package/dist/esm/common/meocord-text.js +11 -8
- package/dist/esm/common/metadata.js +1 -1
- package/dist/esm/common/response/call-order.js +9 -4
- package/dist/esm/common/response/presenter.js +33 -6
- package/dist/esm/common/response/response-state.js +84 -10
- package/dist/esm/common/route.js +21 -5
- package/dist/esm/common/sharded-cooldown-store.js +1 -1
- package/dist/esm/common/translate-error.js +1 -1
- package/dist/esm/common/translator.js +37 -6
- package/dist/esm/core/command-conflicts.js +34 -34
- package/dist/esm/core/command-registration.js +93 -24
- package/dist/esm/core/command-routes.js +2 -2
- package/dist/esm/core/component-routes.js +74 -31
- package/dist/esm/core/cooldown-runner.js +29 -8
- package/dist/esm/core/dispatcher.js +51 -7
- package/dist/esm/core/fallback.js +20 -11
- package/dist/esm/core/filter-runner.js +2 -2
- package/dist/esm/core/guard-runner.js +16 -3
- package/dist/esm/core/handler-input.js +4 -3
- package/dist/esm/core/handler-pipeline.js +27 -14
- package/dist/esm/core/handler-registry.js +1 -1
- package/dist/esm/core/interceptor-runner.js +2 -2
- package/dist/esm/core/lifecycle-hooks.js +134 -47
- package/dist/esm/core/meocord-factory.js +241 -179
- package/dist/esm/core/meocord.app.js +116 -75
- package/dist/esm/core/message-routes.js +4 -3
- package/dist/esm/core/observer-runner.js +4 -3
- package/dist/esm/core/providers.js +45 -23
- package/dist/esm/core/route-specificity.js +4 -4
- package/dist/esm/core/running-calls.js +43 -0
- package/dist/esm/core/scalar-types.js +15 -3
- package/dist/esm/core/shard-context.js +6 -7
- package/dist/esm/core/shard-manager.js +16 -10
- package/dist/esm/core/startup-roots.js +23 -4
- package/dist/esm/core/theme-resolvers.js +4 -1
- package/dist/esm/core/theme-runtime.js +2 -2
- package/dist/esm/core/theme-scope.js +3 -1
- package/dist/esm/core/theme-validation.js +17 -10
- package/dist/esm/decorator/app.decorator.js +12 -7
- package/dist/esm/decorator/command-builder.decorator.js +4 -4
- package/dist/esm/decorator/controller-class.decorator.js +9 -4
- package/dist/esm/decorator/controller.decorator.js +162 -83
- package/dist/esm/decorator/cooldown.decorator.js +20 -3
- package/dist/esm/decorator/defer.decorator.js +4 -4
- package/dist/esm/decorator/event.decorator.js +5 -5
- package/dist/esm/decorator/filter.decorator.js +7 -7
- package/dist/esm/decorator/guard.decorator.js +11 -10
- package/dist/esm/decorator/inject.decorator.js +4 -4
- package/dist/esm/decorator/interceptor.decorator.js +7 -7
- package/dist/esm/decorator/observer.decorator.js +4 -4
- package/dist/esm/decorator/service.decorator.js +4 -4
- package/dist/esm/decorator/theme.decorator.js +3 -3
- package/dist/esm/decorator/validation.decorator.js +10 -10
- package/dist/esm/package.json.js +1 -1
- package/dist/esm/testing/catalog.js +27 -11
- package/dist/esm/testing/cooldown-store-suite.js +85 -15
- package/dist/esm/testing/discord-defaults.js +249 -21
- package/dist/esm/testing/index.js +4 -2
- package/dist/esm/testing/inspect-handler.js +20 -3
- package/dist/esm/testing/meocord-testing-module.js +352 -205
- package/dist/esm/testing/mock-fn.js +173 -22
- package/dist/esm/testing/mock-interaction.js +1214 -209
- package/dist/esm/testing/response.js +1 -1
- package/dist/esm/testing/routing.js +39 -14
- package/dist/esm/testing/snowflake.js +9 -2
- package/dist/esm/testing/startup-errors.js +26 -0
- package/dist/esm/testing/strict-mocks.js +65 -0
- package/dist/esm/testing/theme.js +1 -1
- package/dist/esm/util/bundle-entry.util.js +2 -1
- package/dist/esm/util/common.util.js +47 -19
- package/dist/esm/util/dev-runner.util.js +8 -8
- package/dist/esm/util/generator-cli.util.js +4 -5
- package/dist/esm/util/interaction.util.js +3 -3
- package/dist/esm/util/meocord-config-loader.util.js +10 -2
- package/dist/esm/util/meocord-config-validation.util.js +4 -5
- package/dist/esm/util/meocord-source-config.util.js +65 -20
- package/dist/esm/util/metadata-keys.js +1 -0
- package/dist/esm/util/method-wrapper.util.js +16 -0
- package/dist/esm/util/platform.util.js +10 -10
- package/dist/esm/util/refusal.util.js +148 -8
- package/dist/esm/util/shard-process.util.js +4 -1
- package/dist/esm/util/sharding-mode.util.js +5 -7
- package/dist/esm/util/shared-state.util.js +21 -0
- package/dist/esm/util/tsconfig-paths.util.js +29 -0
- package/dist/esm/util/tsconfig.util.js +10 -10
- package/dist/esm/util/value.util.js +12 -1
- package/dist/types/common/index.d.cts +8 -8
- package/dist/types/common/index.d.ts +8 -8
- package/dist/types/core/index.d.cts +10 -7
- package/dist/types/core/index.d.ts +10 -7
- package/dist/types/decorator/index.d.cts +31 -29
- package/dist/types/decorator/index.d.ts +31 -29
- package/dist/types/{index-D7n0FBMm.d.ts → index-DKJclIi-.d.ts} +107 -36
- package/dist/types/{index-DEP0NYrU.d.cts → index-Dlf0ZZtS.d.cts} +107 -36
- package/dist/types/interface/index.d.cts +1 -1
- package/dist/types/interface/index.d.ts +1 -1
- package/dist/types/{route-C-aMI7Sl.d.cts → route-Bh1U13yy.d.cts} +8 -4
- package/dist/types/{route-C-aMI7Sl.d.ts → route-Bh1U13yy.d.ts} +8 -4
- package/dist/types/testing/index.d.cts +406 -70
- package/dist/types/testing/index.d.ts +406 -70
- package/dist/types/{theme-resolvers-COHdX5g6.d.cts → theme-resolvers-CyEz9pnv.d.cts} +1 -1
- package/dist/types/{theme-resolvers-DedxyscT.d.ts → theme-resolvers-DA204O-q.d.ts} +1 -1
- package/dist/types/{translator-J6tIUfdp.d.ts → translator-Bg7hwxDF.d.cts} +21 -8
- package/dist/types/{translator-B4mc1lzJ.d.cts → translator-CeP4bCzp.d.ts} +21 -8
- package/package.json +4 -3
|
@@ -6,8 +6,8 @@ import { ReactionHandlerAction } from '../enum/controller.enum.js';
|
|
|
6
6
|
import { CooldownStore, MemoryCooldownStore } from '../common/cooldown-store.js';
|
|
7
7
|
import { ExecutionContext } from '../common/execution-context.js';
|
|
8
8
|
import { Translator, missingTranslatorError } from '../common/translator.js';
|
|
9
|
-
import { injectedTokens, singletonContextError } from '../core/guard-runner.js';
|
|
10
|
-
import { appPresenterOf, runHandler, appStages, bindGlobalStages, prepareHandlerStages, bindAppPresenter } from '../core/handler-pipeline.js';
|
|
9
|
+
import { injectedTokens, singletonContextError, recordInstanceContainers } from '../core/guard-runner.js';
|
|
10
|
+
import { appPresenterOf, runHandler, appStages, bindGlobalStages, prepareHandlerStages, prepareAppPresenter, prepareThemes, bindAppPresenter } from '../core/handler-pipeline.js';
|
|
11
11
|
import { setPresenter } from '../common/response/presenter.js';
|
|
12
12
|
import { componentRouteFor, commandMismatch, handlerInput } from '../core/handler-input.js';
|
|
13
13
|
import { buildComponentRoutes } from '../core/component-routes.js';
|
|
@@ -21,21 +21,26 @@ import { messageParamsFor, buildMessageRoutes } from '../core/message-routes.js'
|
|
|
21
21
|
import { assertDistinctCommands, warnUnregisteredCommands, warnInheritedRoutes, warnOverlappingPatterns, warnHandlersOffControllers } from '../core/command-conflicts.js';
|
|
22
22
|
import { messageCommandHooks } from '../core/message-params.js';
|
|
23
23
|
import { appObservers, assertObservers, bindObservers } from '../core/observer-runner.js';
|
|
24
|
-
import { makeInjectable } from '../util/injectable.util.js';
|
|
25
24
|
import { HandlerRegistry } from '../core/handler-registry.js';
|
|
26
25
|
import { adviseInPlaceOfInjecting, meocordClasses } from '../core/meocord-classes.js';
|
|
27
|
-
import { startupClasses, assertStartupClasses, lateGuardCheck } from '../core/startup-roots.js';
|
|
26
|
+
import { startupClasses, assertStartupClasses, lateGuardCheck, stageDependencies } from '../core/startup-roots.js';
|
|
28
27
|
import { markStartupChecked, assertStartupChecked } from '../core/startup-checked.js';
|
|
28
|
+
import { makeInjectable } from '../util/injectable.util.js';
|
|
29
29
|
import { ShardContext, shardCallHandler } from '../core/shard-context.js';
|
|
30
30
|
import { isAppClassToken } from '../core/lifecycle-order.js';
|
|
31
|
-
import {
|
|
32
|
-
import { createMockClient } from './mock-interaction.js';
|
|
31
|
+
import { runShutdownSequence, runReadyHooks, ReadyPass, lifecycleEntry, callsSettled } from '../core/lifecycle-hooks.js';
|
|
32
|
+
import { createMockClient, noteReactionDispatch } from './mock-interaction.js';
|
|
33
33
|
import { Dispatcher } from '../core/dispatcher.js';
|
|
34
34
|
import { createFallback } from '../core/fallback.js';
|
|
35
35
|
import { Logger } from '../common/logger.js';
|
|
36
|
+
import { isExplainedError, markExplained } from '../common/explained-error.js';
|
|
37
|
+
import { declaredErrorsOf, collectStartupErrors, startupError, stopOnStartupErrors, isRefusal, logStartupErrors } from '../util/refusal.util.js';
|
|
36
38
|
import { DEFAULT_SHUTDOWN_TIMEOUT_MS, shutdownTimeoutProblem } from '../util/shutdown-timeout.util.js';
|
|
37
39
|
import { resolveProviders, tokenName, bindsOwnToken, providerMap, bindProvider, resolutionOrder, isClassProvider, assertProvided, tokenDependencies } from '../core/providers.js';
|
|
38
40
|
import { getEventHandlers } from '../decorator/event.decorator.js';
|
|
41
|
+
import { getReactionHandlers } from '../decorator/controller.decorator.js';
|
|
42
|
+
import { strictMocks } from './strict-mocks.js';
|
|
43
|
+
import { warnOnce } from '../common/deprecation.js';
|
|
39
44
|
import { META } from '../util/metadata-keys.js';
|
|
40
45
|
import { isUserOutcome } from '../common/user-outcome.js';
|
|
41
46
|
|
|
@@ -69,7 +74,7 @@ import { isUserOutcome } from '../common/user-outcome.js';
|
|
|
69
74
|
* @category Module
|
|
70
75
|
* @see {@link MeoCordTestingModule}
|
|
71
76
|
* @see {@link getResponse}
|
|
72
|
-
* @see {@link https://meocord.dev/docs/4.
|
|
77
|
+
* @see {@link https://meocord.dev/docs/4.2/invoke-and-dispatch | Invoke and dispatch}
|
|
73
78
|
*/ class TestingModule {
|
|
74
79
|
/**
|
|
75
80
|
* Resolves the module's `useFactory` providers, awaiting those that return a promise, in dependency
|
|
@@ -108,7 +113,7 @@ import { isUserOutcome } from '../common/user-outcome.js';
|
|
|
108
113
|
await this.resolving;
|
|
109
114
|
if (options.ready) {
|
|
110
115
|
const { client = createMockClient(), primary = true } = options.ready === true ? {} : options.ready;
|
|
111
|
-
this.readying ?? (this.readying = this.runReady(client, primary));
|
|
116
|
+
this.readying ?? (this.readying = this.readyPass.run(()=>this.runReady(client, primary)));
|
|
112
117
|
await this.readying;
|
|
113
118
|
}
|
|
114
119
|
return this;
|
|
@@ -123,12 +128,21 @@ import { isUserOutcome } from '../common/user-outcome.js';
|
|
|
123
128
|
* It first gives up the theme read outside calls, if `init({ ready: true })` made it this module's;
|
|
124
129
|
* reads outside calls then return MeoCord's defaults until another module or app is ready.
|
|
125
130
|
*
|
|
131
|
+
* A stopped bot runs nothing more. Once `close()` is called, a module still runs what `dispatch`, `invoke` and `emit`
|
|
132
|
+
* are given, against services shut down, or shutting down, and warns once that it is closed, so a `close()` in the
|
|
133
|
+
* wrong place shows.
|
|
134
|
+
*
|
|
126
135
|
* The cooldown store and what it injects shut down last, in the same sequence as the bot's. When any of them has an
|
|
127
136
|
* `onShutdown`, the calls `invoke`, `dispatch` and `emit` have under way finish first, then the store operations
|
|
128
137
|
* they started, so the store's last writes still reach it. The module stops waiting for the whole sequence after
|
|
129
138
|
* its `shutdownTimeout`, 10 seconds unless set, and logs that it did. Give a test whose fake store never answers, or
|
|
130
139
|
* whose `onShutdown` never settles, a short `shutdownTimeout`.
|
|
131
140
|
*
|
|
141
|
+
* A `close()` while `init({ ready: true })` runs the `onReady` hooks waits for them first, within the same
|
|
142
|
+
* `shutdownTimeout`. An `onReady` still running once only the hooks' share of it is left is named in a warning, and
|
|
143
|
+
* its class is shut down all the same, as everything the module constructed is. One that calls `close()` itself isn't
|
|
144
|
+
* waited for.
|
|
145
|
+
*
|
|
132
146
|
* @returns Once every hook has run. Rejects with the error of a hook that failed, or an
|
|
133
147
|
* `AggregateError` naming each when several did.
|
|
134
148
|
*
|
|
@@ -143,14 +157,18 @@ import { isUserOutcome } from '../common/user-outcome.js';
|
|
|
143
157
|
* expect(module.get(POOL).ended).toBe(true)
|
|
144
158
|
* ```
|
|
145
159
|
*/ async close() {
|
|
160
|
+
// An onReady awaiting close() would otherwise hold up the close that waits for it
|
|
161
|
+
this.readyPass.excuseCurrent();
|
|
146
162
|
this.closing ?? (this.closing = (async ()=>{
|
|
147
|
-
// A close during init waits for the
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
.
|
|
163
|
+
// A close during init waits for the providers it started, and the sequence for its onReady hooks, so it shuts
|
|
164
|
+
// down whatever they constructed
|
|
165
|
+
await this.resolving?.catch(()=>undefined);
|
|
166
|
+
const entries = ()=>this.lifecycle.filter((unit)=>this.constructed.has(unit.token))// Already made, so this returns the instance; a provided value may be anything, null included
|
|
167
|
+
.map((unit)=>lifecycleEntry(unit, this.container.get(unit.token) ?? {}));
|
|
151
168
|
const failures = [];
|
|
152
169
|
await runShutdownSequence(this.container, entries, {
|
|
153
170
|
drainCalls: ()=>callsSettled(this.calls),
|
|
171
|
+
readyPass: this.readyPass,
|
|
154
172
|
timeoutMs: this.shutdownTimeout,
|
|
155
173
|
hookFailed: (name, error)=>failures.push({
|
|
156
174
|
name,
|
|
@@ -164,6 +182,11 @@ import { isUserOutcome } from '../common/user-outcome.js';
|
|
|
164
182
|
})());
|
|
165
183
|
await this.closing;
|
|
166
184
|
}
|
|
185
|
+
/** Warns once, after `close()`, that a call runs against services already shut down. */ warnIfClosed() {
|
|
186
|
+
if (this.closing === undefined || this.warnedClosed) return;
|
|
187
|
+
this.warnedClosed = true;
|
|
188
|
+
new Logger('TestingModule').warn('This testing module is closed, and its services are shut down, or shutting down, so dispatch, invoke and emit run ' + 'against that state. Close it after the last call, in afterEach.');
|
|
189
|
+
}
|
|
167
190
|
async runReady(client, primary) {
|
|
168
191
|
// As the bot claims it before it comes online, so onReady reads the app's theme
|
|
169
192
|
claimAmbientAppTheme(this.container);
|
|
@@ -179,6 +202,8 @@ import { isUserOutcome } from '../common/user-outcome.js';
|
|
|
179
202
|
}, [], {
|
|
180
203
|
resolveFailed: failed,
|
|
181
204
|
hookFailed: failed
|
|
205
|
+
}, {
|
|
206
|
+
runOnReady: this.readyPass.runOnReady
|
|
182
207
|
});
|
|
183
208
|
throwFailures('onReady', failures);
|
|
184
209
|
}
|
|
@@ -250,7 +275,9 @@ import { isUserOutcome } from '../common/user-outcome.js';
|
|
|
250
275
|
* @returns Whether the handler ran, and the error a filter handled, if any. Rejects with an error no
|
|
251
276
|
* filter handles, or with the error a filter throws: the built-in fallback, which answers such
|
|
252
277
|
* errors in the bot, does not run here. Rejects before running anything with an interaction or a
|
|
253
|
-
* message the handler's route does not match, naming both.
|
|
278
|
+
* message the handler's route does not match, naming both. Given fewer arguments than the handler
|
|
279
|
+
* declares, with a first that is neither an interaction nor a message, such as a reaction without its
|
|
280
|
+
* `ReactionEvent`, it rejects under `useStrictMocks()`, and otherwise warns once and runs the handler.
|
|
254
281
|
*
|
|
255
282
|
* @example
|
|
256
283
|
* ```ts
|
|
@@ -272,6 +299,7 @@ import { isUserOutcome } from '../common/user-outcome.js';
|
|
|
272
299
|
* expect(outcome.error).toBeInstanceOf(GuardDeniedError)
|
|
273
300
|
* ```
|
|
274
301
|
*/ async invoke(controller, methodName, ...args) {
|
|
302
|
+
this.warnIfClosed();
|
|
275
303
|
await this.init();
|
|
276
304
|
if (!this.controllers.includes(controller)) {
|
|
277
305
|
throw new Error(`${controller.name} is not a controller of this testing module. Add it to \`controllers\`.`);
|
|
@@ -301,6 +329,7 @@ import { isUserOutcome } from '../common/user-outcome.js';
|
|
|
301
329
|
hooks = messageCommandHooks(route, params, first, start, given, this.messageOptions.types);
|
|
302
330
|
}
|
|
303
331
|
}
|
|
332
|
+
this.checkArgumentCount(controller, methodName, instance[methodName], first, callArgs.length);
|
|
304
333
|
const presenter = appPresenterOf(this.container);
|
|
305
334
|
// A message command's errors are drawn by the app's presenter too, as an interaction's are
|
|
306
335
|
const client = first instanceof BaseInteraction || first instanceof Message ? first.client : undefined;
|
|
@@ -317,6 +346,19 @@ import { isUserOutcome } from '../common/user-outcome.js';
|
|
|
317
346
|
error
|
|
318
347
|
};
|
|
319
348
|
}
|
|
349
|
+
/**
|
|
350
|
+
* Refuses under `useStrictMocks()`, or warns once otherwise, a call that gives a handler fewer arguments than it
|
|
351
|
+
* declares when `invoke` builds none for it, as for a reaction given without its `ReactionEvent`.
|
|
352
|
+
*/ checkArgumentCount(controller, methodName, method, first, given) {
|
|
353
|
+
if (first instanceof BaseInteraction || first instanceof Message || method.length <= given) return;
|
|
354
|
+
const reaction = getReactionHandlers(controller.prototype).some((handler)=>handler.method === methodName);
|
|
355
|
+
const problem = `${controller.name}.${methodName} takes ${method.length} arguments, and invoke was given ${given}: ` + (reaction ? 'pass its ReactionEvent after the reaction, as { user, action }.' : 'pass each argument it takes.');
|
|
356
|
+
if (strictMocks()) throw new Error(problem);
|
|
357
|
+
const key = `${controller.name}.${methodName}`;
|
|
358
|
+
if (this.warnedArgumentCounts.has(key)) return;
|
|
359
|
+
this.warnedArgumentCounts.add(key);
|
|
360
|
+
new Logger('TestingModule').warn(problem);
|
|
361
|
+
}
|
|
320
362
|
/** Counts `call` as under way until it settles. */ track(call) {
|
|
321
363
|
this.calls.add(call);
|
|
322
364
|
void call.then(()=>this.calls.delete(call), ()=>this.calls.delete(call));
|
|
@@ -343,6 +385,8 @@ import { isUserOutcome } from '../common/user-outcome.js';
|
|
|
343
385
|
controllerClasses: this.controllers,
|
|
344
386
|
messageOptions: this.messageOptions,
|
|
345
387
|
logger,
|
|
388
|
+
// A test's fake clock can pass 5 s inside a handler, so the slow-handler warning waits to be asked for
|
|
389
|
+
warnSlowMessageHandlers: false,
|
|
346
390
|
// Strict: an answer MeoCord fails to build rejects the dispatch, where a bot logs it and carries on
|
|
347
391
|
fallback: createFallback(logger, ()=>this.messageOptions, {
|
|
348
392
|
strict: true,
|
|
@@ -360,6 +404,7 @@ import { isUserOutcome } from '../common/user-outcome.js';
|
|
|
360
404
|
return this.dispatcher;
|
|
361
405
|
}
|
|
362
406
|
async dispatch(input, options) {
|
|
407
|
+
this.warnIfClosed();
|
|
363
408
|
await this.init();
|
|
364
409
|
this.registerClient(input);
|
|
365
410
|
const dispatcher = this.dispatcherOf();
|
|
@@ -377,6 +422,7 @@ import { isUserOutcome } from '../common/user-outcome.js';
|
|
|
377
422
|
unhandled: (error)=>unhandled.push(error)
|
|
378
423
|
};
|
|
379
424
|
if (options) {
|
|
425
|
+
noteReactionDispatch(input);
|
|
380
426
|
await this.track(dispatcher.reaction(input, {
|
|
381
427
|
user: options.user,
|
|
382
428
|
action: options.action ?? ReactionHandlerAction.ADD
|
|
@@ -406,15 +452,20 @@ import { isUserOutcome } from '../common/user-outcome.js';
|
|
|
406
452
|
};
|
|
407
453
|
}
|
|
408
454
|
/**
|
|
409
|
-
* Emits a client event to the module's `@On` and `@Once` handlers, through the
|
|
410
|
-
*
|
|
411
|
-
* module's controllers, class providers and their dependencies all receive it. A
|
|
412
|
-
* handles only the first event, as it would on a client.
|
|
455
|
+
* Emits a client event to the module's `@On` and `@Once` handlers, through the pipeline the app runs
|
|
456
|
+
* them in up to its event fallback: the global guards of the module's `app`, then each handler's own.
|
|
457
|
+
* Handlers on the module's controllers, class providers and their dependencies all receive it. A
|
|
458
|
+
* `@Once` handler handles only the first event, as it would on a client.
|
|
459
|
+
*
|
|
460
|
+
* The bot's event fallback doesn't run here. On a bot, a guard's `GuardDeniedError` is logged and the
|
|
461
|
+
* event skipped, and a handler's `UserError` is logged and, when the event carries a message, answered
|
|
462
|
+
* as a reply to it; here both reject `emit` as any error does, so a test checks them with `rejects`.
|
|
413
463
|
*
|
|
414
464
|
* @param event - The client event, such as `'guildMemberAdd'`.
|
|
415
465
|
* @param args - The event's arguments, typed from discord.js's `ClientEvents`.
|
|
416
|
-
* @returns How many handlers ran. Rejects once every handler has settled if any threw
|
|
417
|
-
*
|
|
466
|
+
* @returns How many handlers ran. Rejects once every handler has settled if any threw, a guard's
|
|
467
|
+
* `GuardDeniedError` and a handler's `UserError` included: with that error when one handler failed,
|
|
468
|
+
* or an `AggregateError` of them when several did.
|
|
418
469
|
*
|
|
419
470
|
* @example
|
|
420
471
|
* ```ts
|
|
@@ -426,6 +477,7 @@ import { isUserOutcome } from '../common/user-outcome.js';
|
|
|
426
477
|
* expect(ran).toBe(1)
|
|
427
478
|
* ```
|
|
428
479
|
*/ async emit(event, ...args) {
|
|
480
|
+
this.warnIfClosed();
|
|
429
481
|
await this.init();
|
|
430
482
|
const calls = [];
|
|
431
483
|
for (const cls of this.eventClasses){
|
|
@@ -470,7 +522,10 @@ import { isUserOutcome } from '../common/user-outcome.js';
|
|
|
470
522
|
this.appWarnUnanswered = appWarnUnanswered;
|
|
471
523
|
this.services = services;
|
|
472
524
|
this.shutdownTimeout = shutdownTimeout;
|
|
525
|
+
/** The `onReady` hooks' pass, which `close()` waits for, less one whose `onReady` called `close()`. */ this.readyPass = new ReadyPass();
|
|
526
|
+
this.warnedClosed = false;
|
|
473
527
|
/** The `@Once` handlers that have already handled their event, as a client forgets its once listeners. */ this.firedOnce = new Set();
|
|
528
|
+
/** The handlers already warned about for being given fewer arguments than they take, so each is named once. */ this.warnedArgumentCounts = new Set();
|
|
474
529
|
/** The calls under way, each removed once it settles: what `close()` lets finish before the cooldown store stops. */ this.calls = new Set();
|
|
475
530
|
}
|
|
476
531
|
}
|
|
@@ -509,7 +564,7 @@ import { isUserOutcome } from '../common/user-outcome.js';
|
|
|
509
564
|
* @group Testing
|
|
510
565
|
* @category Module
|
|
511
566
|
* @see {@link MeoCordTestingModule}
|
|
512
|
-
* @see {@link https://meocord.dev/docs/4.
|
|
567
|
+
* @see {@link https://meocord.dev/docs/4.2/testing | The testing module}
|
|
513
568
|
*/ class TestingModuleBuilder {
|
|
514
569
|
/**
|
|
515
570
|
* Replaces a provider with a test double.
|
|
@@ -535,6 +590,10 @@ import { isUserOutcome } from '../common/user-outcome.js';
|
|
|
535
590
|
/**
|
|
536
591
|
* Replaces a guard with a stub wherever it applies, globally or on a controller or handler.
|
|
537
592
|
*
|
|
593
|
+
* It stands in for the guard wherever the guard is bound, a provider of the same token included, and wins over an
|
|
594
|
+
* `overrideProvider` of it. The stub needs a `canActivate`, its own or its prototype's: `compile()` refuses one without
|
|
595
|
+
* it under `useStrictMocks()`, and otherwise warns.
|
|
596
|
+
*
|
|
538
597
|
* @param guard - The guard class to replace.
|
|
539
598
|
* @example
|
|
540
599
|
* ```ts
|
|
@@ -552,6 +611,10 @@ import { isUserOutcome } from '../common/user-outcome.js';
|
|
|
552
611
|
* Replaces an interceptor with a stub wherever it applies, globally or on a controller or handler.
|
|
553
612
|
* The stub's `intercept` receives the context and `next`; call `next.handle()` to run the handler.
|
|
554
613
|
*
|
|
614
|
+
* It stands in for the interceptor wherever it is bound, a provider of the same token included, and wins over an
|
|
615
|
+
* `overrideProvider` of it. A stub without `intercept` is refused by `compile()` under `useStrictMocks()`, and
|
|
616
|
+
* otherwise warned about.
|
|
617
|
+
*
|
|
555
618
|
* @param interceptor - The interceptor class to replace.
|
|
556
619
|
* @example
|
|
557
620
|
* ```ts
|
|
@@ -569,6 +632,10 @@ import { isUserOutcome } from '../common/user-outcome.js';
|
|
|
569
632
|
* Replaces an exception filter with a stub wherever it applies. The filter's `@Catch` still decides
|
|
570
633
|
* which errors reach the stub.
|
|
571
634
|
*
|
|
635
|
+
* It stands in for the filter wherever it is bound, a provider of the same token included, and wins over an
|
|
636
|
+
* `overrideProvider` of it. A stub without `catch` is refused by `compile()` under `useStrictMocks()`, and otherwise
|
|
637
|
+
* warned about.
|
|
638
|
+
*
|
|
572
639
|
* @param filter - The filter class to replace.
|
|
573
640
|
* @example
|
|
574
641
|
* ```ts
|
|
@@ -657,199 +724,271 @@ import { isUserOutcome } from '../common/user-outcome.js';
|
|
|
657
724
|
* its command's builder does not register, a customId pattern given as a command name, a builder that registers
|
|
658
725
|
* another name, and component patterns that can match the same customId are named, as the bot names them.
|
|
659
726
|
*
|
|
727
|
+
* Each check reports every startup error it finds, and the first is thrown, unchanged; when there are several, each
|
|
728
|
+
* is logged first, with the file it comes from. After {@link reportAllStartupErrors}, the errors decorators kept on
|
|
729
|
+
* the classes the module runs are among them.
|
|
730
|
+
*
|
|
660
731
|
* @returns The compiled module.
|
|
732
|
+
* @throws Error for the first startup error the checks find.
|
|
661
733
|
*/ compile() {
|
|
662
734
|
// The bound the bot's config gives it, with the same words
|
|
663
735
|
const shutdownTimeout = this.options.shutdownTimeout === undefined ? undefined : shutdownTimeoutProblem(this.options.shutdownTimeout);
|
|
664
736
|
if (shutdownTimeout) throw new TypeError(`shutdownTimeout ${shutdownTimeout}.`);
|
|
665
|
-
|
|
666
|
-
const
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
const appClasses = [];
|
|
670
|
-
const translator = ()=>container.isBound(Translator) ? container.get(Translator) : undefined;
|
|
671
|
-
container.bind(HandlerRegistry).toConstantValue(new HandlerRegistry(appClasses, messagesOf(this.options.app), translator));
|
|
672
|
-
// A testing module runs as one process, so a cross-shard call runs once, here
|
|
673
|
-
container.bind(ShardContext).toConstantValue(new ShardContext(undefined, shardCallHandler(container, ()=>[
|
|
674
|
-
...appClasses,
|
|
675
|
-
...[
|
|
676
|
-
...providers.keys()
|
|
677
|
-
].filter(isAppClassToken)
|
|
678
|
-
], 'this testing module')));
|
|
679
|
-
bindsOwnToken(container, HandlerRegistry);
|
|
680
|
-
bindsOwnToken(container, ShardContext);
|
|
681
|
-
// Checked as the app checks its own: the app's providers in their order, then the test's, which replace them
|
|
682
|
-
// by token, then the overrides, which win
|
|
683
|
-
const providers = providerMap(this.wiring?.providers ?? [], '@MeoCord({ providers })');
|
|
684
|
-
for (const [token, provider] of providerMap(this.options.providers ?? [], "the testing module's providers"))providers.set(token, provider);
|
|
685
|
-
for (const [token, override] of this.overrides)providers.set(token, override);
|
|
686
|
-
const services = this.wiring?.services ?? [];
|
|
687
|
-
const appOptions = this.options.app ? Reflect.getMetadata(META.appOptions, this.options.app) : undefined;
|
|
688
|
-
// The app's own store, as the bot binds it, whether the module is given the app or built from it; none when the
|
|
689
|
-
// test provides the CooldownStore, so nothing builds the app's or asks for what it injects
|
|
690
|
-
const store = providers.has(CooldownStore) ? undefined : appOptions?.cooldownStore;
|
|
691
|
-
// The themeFor class this module runs, the app's or overrideThemeFor's, bound on its own as the app binds it
|
|
692
|
-
const themeResolver = themeResolverClass(stages?.themeFor?.resolvers);
|
|
693
|
-
// Every class the module runs, as the app finds them, except stage classes an override stands in for
|
|
694
|
-
const i18n = this.options.app && Reflect.getMetadata(META.appOptions, this.options.app)?.i18n;
|
|
695
|
-
const startup = startupClasses({
|
|
696
|
-
controllers: this.options.controllers ?? [],
|
|
697
|
-
services,
|
|
698
|
-
providers,
|
|
699
|
-
stages,
|
|
700
|
-
presenter: this.options.app && Reflect.getMetadata(META.appOptions, this.options.app)?.presenter,
|
|
701
|
-
cooldownStore: store,
|
|
702
|
-
themeFor: stages?.themeFor?.resolvers,
|
|
703
|
-
observers: [
|
|
704
|
-
...this.options.app ? appObservers(this.options.app) : [],
|
|
705
|
-
...this.options.observers ?? []
|
|
706
|
-
],
|
|
707
|
-
stubs: [
|
|
708
|
-
...this.guardOverrides.keys(),
|
|
709
|
-
...this.interceptorOverrides.keys(),
|
|
710
|
-
...this.filterOverrides.keys()
|
|
711
|
-
]
|
|
712
|
-
});
|
|
713
|
-
const hasTranslator = i18n !== undefined || providers.has(Translator);
|
|
714
|
-
assertStartupClasses(startup, {
|
|
715
|
-
translator: hasTranslator
|
|
716
|
-
});
|
|
717
|
-
markStartupChecked(container, [
|
|
718
|
-
...startup.classes,
|
|
719
|
-
...meocordClasses()
|
|
720
|
-
], lateGuardCheck(providers, hasTranslator));
|
|
721
|
-
// The bot binds the Client it logs in with; a test gives its own, and is told so where one is needed
|
|
722
|
-
const needClient = startup.classes.filter((cls)=>injectedTokens(cls).includes(Client));
|
|
723
|
-
if (needClient.length > 0 && !providers.has(Client)) {
|
|
724
|
-
container.bind(Client).toDynamicValue(()=>{
|
|
725
|
-
throw new Error(`${needClient.map((cls)=>cls.name).join(', ')} ${needClient.length === 1 ? 'injects' : 'inject'} the Discord Client, ` + 'which a testing module does not make: give one in its providers, such as ' + '{ provide: Client, useValue: createMockClient() }.');
|
|
726
|
-
});
|
|
727
|
-
}
|
|
728
|
-
// Bind guard overrides — prevents inversify from auto-wiring guard dependencies
|
|
729
|
-
for (const [guardClass, stub] of this.guardOverrides){
|
|
730
|
-
container.bind(guardClass).toConstantValue(stub);
|
|
731
|
-
}
|
|
732
|
-
for (const [filterClass, stub] of this.filterOverrides){
|
|
733
|
-
container.bind(filterClass).toConstantValue(stub);
|
|
734
|
-
}
|
|
735
|
-
for (const [interceptorClass, stub] of this.interceptorOverrides){
|
|
736
|
-
container.bind(interceptorClass).toConstantValue(stub);
|
|
737
|
-
}
|
|
738
|
-
// The app's cooldown policy, so a test of a failing store sees what the bot would do
|
|
739
|
-
if (appOptions && !container.isBound(COOLDOWN_POLICY)) container.bind(COOLDOWN_POLICY).toConstantValue(cooldownPolicyFrom(appOptions));
|
|
740
|
-
// The app's translator, unless a provider stands in for it
|
|
741
|
-
if (i18n && !providers.has(Translator)) {
|
|
742
|
-
container.bind(Translator).toConstantValue(i18n);
|
|
743
|
-
bindsOwnToken(container, Translator);
|
|
744
|
-
}
|
|
745
|
-
// Recursively bind controllers and their dependencies, skipping already-bound tokens
|
|
746
|
-
const bindClass = (cls)=>{
|
|
747
|
-
// A provided class is bound by its own provider, wherever in the list that provider comes
|
|
748
|
-
if (container.isBound(cls) || providers.has(cls)) return;
|
|
749
|
-
if (injectedTokens(cls).includes(ExecutionContext)) throw singletonContextError(cls);
|
|
750
|
-
assertStartupChecked(container, cls);
|
|
751
|
-
makeInjectable(cls);
|
|
752
|
-
container.bind(cls).toSelf().inSingletonScope();
|
|
753
|
-
// By constructor type or @inject token, as the app binds them
|
|
754
|
-
for (const dep of injectedTokens(cls)){
|
|
755
|
-
if (dep === Translator && !container.isBound(Translator)) throw missingTranslatorError(cls);
|
|
756
|
-
if (isAppClassToken(dep)) bindClass(dep);
|
|
757
|
-
}
|
|
758
|
-
};
|
|
759
|
-
// The app's own store, resolved like a service, unless the test provides the CooldownStore itself; bound first, as the
|
|
760
|
-
// app binds it, so a class that injects the token gets the store
|
|
761
|
-
if (!providers.has(CooldownStore)) {
|
|
762
|
-
if (store) {
|
|
763
|
-
bindClass(store);
|
|
764
|
-
container.bind(CooldownStore).toService(store);
|
|
765
|
-
} else {
|
|
766
|
-
container.bind(CooldownStore).toConstantValue(new MemoryCooldownStore());
|
|
767
|
-
}
|
|
768
|
-
bindsOwnToken(container, CooldownStore, store);
|
|
769
|
-
}
|
|
770
|
-
// Before the controllers, so a class token that is provided is not also bound as itself
|
|
771
|
-
for (const provider of providers.values())bindProvider(container, provider, bindClass);
|
|
772
|
-
for (const ctrl of this.options.controllers ?? []){
|
|
773
|
-
bindClass(ctrl);
|
|
774
|
-
// Stamp container on controller class so @UseGuard works in tests too
|
|
775
|
-
Reflect.defineMetadata(META.container, container, ctrl);
|
|
776
|
-
}
|
|
777
|
-
for (const service of services)bindClass(service);
|
|
778
|
-
if (themeResolver) bindClass(themeResolver);
|
|
779
|
-
// The classes whose @On and @Once handlers emit reaches: class providers bound as themselves, the
|
|
780
|
-
// controllers, and what they inject; factories resolve in the same order
|
|
781
|
-
const order = resolutionOrder(container, providers, [
|
|
782
|
-
...providers.keys(),
|
|
783
|
-
...services,
|
|
784
|
-
...themeResolver ? [
|
|
785
|
-
themeResolver
|
|
786
|
-
] : [],
|
|
787
|
-
...this.options.controllers ?? []
|
|
788
|
-
], {
|
|
789
|
-
followOwnTokens: false
|
|
790
|
-
});
|
|
791
|
-
appClasses.push(...order.filter((token)=>{
|
|
792
|
-
const provider = providers.get(token);
|
|
793
|
-
return isAppClassToken(token) && (!provider || isClassProvider(provider) && provider.useClass === token);
|
|
794
|
-
}));
|
|
795
|
-
assertProvided(container, providers, startup.classes, "the testing module's providers");
|
|
796
|
-
// Every class the module runs, so @UseGuard resolves guards on a direct call to any of them
|
|
797
|
-
for (const cls of startup.classes)Reflect.defineMetadata(META.container, container, cls);
|
|
798
|
-
prepareHandlerStages(container, appClasses);
|
|
799
|
-
const messages = messagesOf(this.options.app);
|
|
800
|
-
// As the app would at startup, refuses a message pattern that cannot be read or two that match the same messages
|
|
801
|
-
buildMessageRoutes(this.options.controllers ?? [], messages);
|
|
802
|
-
// As the app does when it is created, so a test sees the refusal the bot would give
|
|
803
|
-
assertDistinctCommands(this.options.controllers ?? []);
|
|
804
|
-
// A handler with no builder is how a fixture is written, so only what is always a mistake is named
|
|
805
|
-
warnUnregisteredCommands(this.options.controllers ?? [], {
|
|
806
|
-
missingBuilders: false
|
|
807
|
-
});
|
|
808
|
-
warnInheritedRoutes(this.options.controllers ?? []);
|
|
809
|
-
warnOverlappingPatterns(this.options.controllers ?? []);
|
|
810
|
-
warnHandlersOffControllers(this.options.controllers ?? [], appClasses);
|
|
811
|
-
if (this.options.app) bindAppPresenter(container, this.options.app);
|
|
812
|
-
const observers = [
|
|
813
|
-
...this.options.app ? appObservers(this.options.app) : [],
|
|
814
|
-
...this.options.observers ?? []
|
|
815
|
-
];
|
|
816
|
-
assertObservers("the testing module's observers", observers);
|
|
817
|
-
bindObservers(container, observers);
|
|
818
|
-
// The store calls ask: the test's own when it provides CooldownStore, else the app's
|
|
819
|
-
const boundStore = store ?? (providers.has(CooldownStore) ? CooldownStore : undefined);
|
|
820
|
-
// The order the app runs lifecycle hooks in: its cooldown store, then providers, controllers and observers, each
|
|
821
|
-
// after what it injects
|
|
822
|
-
const lifecycle = resolutionOrder(container, providers, [
|
|
823
|
-
...boundStore ? [
|
|
824
|
-
boundStore
|
|
825
|
-
] : [],
|
|
826
|
-
...providers.keys(),
|
|
827
|
-
...services,
|
|
828
|
-
...themeResolver ? [
|
|
829
|
-
themeResolver
|
|
737
|
+
// With startupErrors: 'all', what the decorators kept on the classes this module runs
|
|
738
|
+
const declared = declaredErrorsOf([
|
|
739
|
+
...this.options.app ? [
|
|
740
|
+
this.options.app
|
|
830
741
|
] : [],
|
|
831
742
|
...this.options.controllers ?? [],
|
|
832
|
-
...
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
743
|
+
...this.wiring?.services ?? [],
|
|
744
|
+
...this.options.observers ?? []
|
|
745
|
+
]);
|
|
746
|
+
// Every error each check finds is reported, as the bot reports them, and the first is thrown
|
|
747
|
+
try {
|
|
748
|
+
return collectStartupErrors(()=>{
|
|
749
|
+
declared.forEach(startupError);
|
|
750
|
+
const container = new Container();
|
|
751
|
+
const stages = this.globalStages();
|
|
752
|
+
if (stages) bindGlobalStages(container, stages);
|
|
753
|
+
// Bound first, as in the app, so a class that injects it gets this instance
|
|
754
|
+
const appClasses = [];
|
|
755
|
+
const translator = ()=>container.isBound(Translator) ? container.get(Translator) : undefined;
|
|
756
|
+
container.bind(HandlerRegistry).toConstantValue(new HandlerRegistry(appClasses, messagesOf(this.options.app), translator));
|
|
757
|
+
// A testing module runs as one process, so a cross-shard call runs once, here
|
|
758
|
+
container.bind(ShardContext).toConstantValue(new ShardContext(undefined, shardCallHandler(container, ()=>[
|
|
759
|
+
...appClasses,
|
|
760
|
+
...[
|
|
761
|
+
...providers.keys()
|
|
762
|
+
].filter(isAppClassToken)
|
|
763
|
+
], 'this testing module')));
|
|
764
|
+
bindsOwnToken(container, HandlerRegistry);
|
|
765
|
+
bindsOwnToken(container, ShardContext);
|
|
766
|
+
// Checked as the app checks its own: the app's providers in their order, then the test's, which replace them
|
|
767
|
+
// by token, then the overrides, which win
|
|
768
|
+
const providers = providerMap(this.wiring?.providers ?? [], '@MeoCord({ providers })');
|
|
769
|
+
for (const [token, provider] of providerMap(this.options.providers ?? [], "the testing module's providers"))providers.set(token, provider);
|
|
770
|
+
for (const [token, override] of this.overrides)providers.set(token, override);
|
|
771
|
+
// A stage override stands in for its class wherever the class is bound. A stage also provided takes the stub in
|
|
772
|
+
// place of its provider, one binding per token, and runs lifecycle hooks as that provider would; any other is
|
|
773
|
+
// bound on its own below, as the bot never makes a lifecycle unit of it. A stub that can't run is named here
|
|
774
|
+
const stageStubs = new Map();
|
|
775
|
+
for (const [method, overrides, kind] of [
|
|
776
|
+
[
|
|
777
|
+
'canActivate',
|
|
778
|
+
this.guardOverrides,
|
|
779
|
+
'overrideGuard'
|
|
780
|
+
],
|
|
781
|
+
[
|
|
782
|
+
'intercept',
|
|
783
|
+
this.interceptorOverrides,
|
|
784
|
+
'overrideInterceptor'
|
|
785
|
+
],
|
|
786
|
+
[
|
|
787
|
+
'catch',
|
|
788
|
+
this.filterOverrides,
|
|
789
|
+
'overrideFilter'
|
|
790
|
+
]
|
|
791
|
+
]){
|
|
792
|
+
for (const [stage, stub] of overrides){
|
|
793
|
+
if (providers.has(stage)) providers.set(stage, {
|
|
794
|
+
provide: stage,
|
|
795
|
+
useValue: stub
|
|
796
|
+
});
|
|
797
|
+
else stageStubs.set(stage, stub);
|
|
798
|
+
if (typeof stub[method] === 'function') continue;
|
|
799
|
+
const problem = `${kind}(${stage.name}).useValue(…) has no ${method} method.`;
|
|
800
|
+
if (strictMocks()) startupError(new Error(problem));
|
|
801
|
+
else warnOnce(new Logger('TestingModule'), `${problem} A call that reaches ${stage.name} fails; useStrictMocks() refuses it here.`);
|
|
802
|
+
}
|
|
803
|
+
}
|
|
804
|
+
// Each pass of checks ends before the next step that is no check, so a module with one error stops where it always did
|
|
805
|
+
stopOnStartupErrors();
|
|
806
|
+
const services = this.wiring?.services ?? [];
|
|
807
|
+
const appOptions = this.options.app ? Reflect.getMetadata(META.appOptions, this.options.app) : undefined;
|
|
808
|
+
// The app's own store, as the bot binds it, whether the module is given the app or built from it; none when the
|
|
809
|
+
// test provides the CooldownStore, so nothing builds the app's or asks for what it injects
|
|
810
|
+
const store = providers.has(CooldownStore) ? undefined : appOptions?.cooldownStore;
|
|
811
|
+
// The themeFor class this module runs, the app's or overrideThemeFor's, bound on its own as the app binds it
|
|
812
|
+
const themeResolver = themeResolverClass(stages?.themeFor?.resolvers);
|
|
813
|
+
// Every class the module runs, as the app finds them, except stage classes an override stands in for
|
|
814
|
+
const i18n = this.options.app && Reflect.getMetadata(META.appOptions, this.options.app)?.i18n;
|
|
815
|
+
const appPresenterClass = this.options.app && Reflect.getMetadata(META.appOptions, this.options.app)?.presenter;
|
|
816
|
+
const startup = startupClasses({
|
|
817
|
+
controllers: this.options.controllers ?? [],
|
|
818
|
+
services,
|
|
819
|
+
providers,
|
|
820
|
+
stages,
|
|
821
|
+
presenter: appPresenterClass,
|
|
822
|
+
cooldownStore: store,
|
|
823
|
+
themeFor: stages?.themeFor?.resolvers,
|
|
824
|
+
observers: [
|
|
825
|
+
...this.options.app ? appObservers(this.options.app) : [],
|
|
826
|
+
...this.options.observers ?? []
|
|
827
|
+
],
|
|
828
|
+
stubs: [
|
|
829
|
+
...this.guardOverrides.keys(),
|
|
830
|
+
...this.interceptorOverrides.keys(),
|
|
831
|
+
...this.filterOverrides.keys()
|
|
832
|
+
]
|
|
833
|
+
});
|
|
834
|
+
const hasTranslator = i18n !== undefined || providers.has(Translator);
|
|
835
|
+
assertStartupClasses(startup, {
|
|
836
|
+
translator: hasTranslator
|
|
837
|
+
});
|
|
838
|
+
// Those of the stages, guards and other classes they reach
|
|
839
|
+
declaredErrorsOf(startup.classes).forEach(startupError);
|
|
840
|
+
stopOnStartupErrors();
|
|
841
|
+
markStartupChecked(container, [
|
|
842
|
+
...startup.classes,
|
|
843
|
+
...meocordClasses()
|
|
844
|
+
], lateGuardCheck(providers, hasTranslator));
|
|
845
|
+
// The bot binds the Client it logs in with; a test gives its own, and is told so where one is needed
|
|
846
|
+
const needClient = startup.classes.filter((cls)=>injectedTokens(cls).includes(Client));
|
|
847
|
+
if (needClient.length > 0 && !providers.has(Client)) {
|
|
848
|
+
container.bind(Client).toDynamicValue(()=>{
|
|
849
|
+
throw new Error(`${needClient.map((cls)=>cls.name).join(', ')} ${needClient.length === 1 ? 'injects' : 'inject'} the Discord Client, ` + 'which a testing module does not make: give one in its providers, such as ' + '{ provide: Client, useValue: createMockClient() }.');
|
|
850
|
+
});
|
|
851
|
+
}
|
|
852
|
+
// A stage override nothing provides, bound before the classes so none of them binds the stage itself
|
|
853
|
+
for (const [stage, stub] of stageStubs)container.bind(stage).toConstantValue(stub);
|
|
854
|
+
// The app's cooldown policy, so a test of a failing store sees what the bot would do
|
|
855
|
+
if (appOptions && !container.isBound(COOLDOWN_POLICY)) container.bind(COOLDOWN_POLICY).toConstantValue(cooldownPolicyFrom(appOptions));
|
|
856
|
+
// The app's translator, unless a provider stands in for it
|
|
857
|
+
if (i18n && !providers.has(Translator)) {
|
|
858
|
+
container.bind(Translator).toConstantValue(i18n);
|
|
859
|
+
bindsOwnToken(container, Translator);
|
|
839
860
|
}
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
861
|
+
// Recursively bind controllers and their dependencies, skipping already-bound tokens
|
|
862
|
+
const bindClass = (cls)=>{
|
|
863
|
+
// A provided class is bound by its own provider, wherever in the list that provider comes
|
|
864
|
+
if (container.isBound(cls) || providers.has(cls)) return;
|
|
865
|
+
if (injectedTokens(cls).includes(ExecutionContext)) return startupError(singletonContextError(cls));
|
|
866
|
+
assertStartupChecked(container, cls);
|
|
867
|
+
makeInjectable(cls);
|
|
868
|
+
container.bind(cls).toSelf().inSingletonScope();
|
|
869
|
+
// By constructor type or @inject token, as the app binds them
|
|
870
|
+
for (const dep of injectedTokens(cls)){
|
|
871
|
+
if (dep === Translator && !container.isBound(Translator)) startupError(missingTranslatorError(cls));
|
|
872
|
+
else if (isAppClassToken(dep)) bindClass(dep);
|
|
873
|
+
}
|
|
874
|
+
};
|
|
875
|
+
// The app's own store, resolved like a service, unless the test provides the CooldownStore itself; bound first, as the
|
|
876
|
+
// app binds it, so a class that injects the token gets the store
|
|
877
|
+
if (!providers.has(CooldownStore)) {
|
|
878
|
+
if (store) {
|
|
879
|
+
bindClass(store);
|
|
880
|
+
container.bind(CooldownStore).toService(store);
|
|
881
|
+
} else {
|
|
882
|
+
container.bind(CooldownStore).toConstantValue(new MemoryCooldownStore());
|
|
883
|
+
}
|
|
884
|
+
bindsOwnToken(container, CooldownStore, store);
|
|
885
|
+
}
|
|
886
|
+
// Before the controllers, so a class token that is provided is not also bound as itself
|
|
887
|
+
for (const provider of providers.values())bindProvider(container, provider, bindClass);
|
|
888
|
+
for (const ctrl of this.options.controllers ?? [])bindClass(ctrl);
|
|
889
|
+
for (const service of services)bindClass(service);
|
|
890
|
+
if (themeResolver) bindClass(themeResolver);
|
|
891
|
+
// What follows walks what these bound, so a class refused here is not reported again as missing
|
|
892
|
+
stopOnStartupErrors();
|
|
893
|
+
// The classes whose @On and @Once handlers emit reaches: class providers bound as themselves, the
|
|
894
|
+
// controllers, and what they inject; factories resolve in the same order
|
|
895
|
+
const order = resolutionOrder(container, providers, [
|
|
896
|
+
...providers.keys(),
|
|
897
|
+
...services,
|
|
898
|
+
...themeResolver ? [
|
|
899
|
+
themeResolver
|
|
900
|
+
] : [],
|
|
901
|
+
...this.options.controllers ?? []
|
|
902
|
+
], {
|
|
903
|
+
followOwnTokens: false
|
|
904
|
+
});
|
|
905
|
+
appClasses.push(...order.filter((token)=>{
|
|
906
|
+
const provider = providers.get(token);
|
|
907
|
+
return isAppClassToken(token) && (!provider || isClassProvider(provider) && provider.useClass === token);
|
|
908
|
+
}));
|
|
909
|
+
assertProvided(container, providers, startup.classes, "the testing module's providers");
|
|
910
|
+
prepareHandlerStages(container, appClasses);
|
|
911
|
+
const messages = messagesOf(this.options.app);
|
|
912
|
+
// As the app would at startup, refuses a message pattern that cannot be read or two that match the same messages
|
|
913
|
+
buildMessageRoutes(this.options.controllers ?? [], messages);
|
|
914
|
+
// As the app does when it is created, so a test sees the refusal the bot would give
|
|
915
|
+
assertDistinctCommands(this.options.controllers ?? []);
|
|
916
|
+
if (this.options.app) prepareAppPresenter(container, this.options.app);
|
|
917
|
+
const observers = [
|
|
918
|
+
...this.options.app ? appObservers(this.options.app) : [],
|
|
919
|
+
...this.options.observers ?? []
|
|
920
|
+
];
|
|
921
|
+
assertObservers("the testing module's observers", observers);
|
|
922
|
+
// Only @Observer classes are bound as observers
|
|
923
|
+
stopOnStartupErrors();
|
|
924
|
+
bindObservers(container, observers);
|
|
925
|
+
// The store calls ask: the test's own when it provides CooldownStore, else the app's
|
|
926
|
+
const boundStore = store ?? (providers.has(CooldownStore) ? CooldownStore : undefined);
|
|
927
|
+
// What the stages and presenter inject, as the bot makes it; a stage the test replaced contributes nothing
|
|
928
|
+
const stageServices = stageDependencies(startup, providers, appPresenterClass);
|
|
929
|
+
for (const service of stageServices)bindClass(service);
|
|
930
|
+
// The order the app runs lifecycle hooks in: its cooldown store, then providers, controllers and observers, each
|
|
931
|
+
// after what it injects
|
|
932
|
+
const lifecycle = resolutionOrder(container, providers, [
|
|
933
|
+
...boundStore ? [
|
|
934
|
+
boundStore
|
|
935
|
+
] : [],
|
|
936
|
+
...providers.keys(),
|
|
937
|
+
...services,
|
|
938
|
+
...stageServices,
|
|
939
|
+
...themeResolver ? [
|
|
940
|
+
themeResolver
|
|
941
|
+
] : [],
|
|
942
|
+
...this.options.controllers ?? [],
|
|
943
|
+
...observers
|
|
944
|
+
]).map((token)=>({
|
|
945
|
+
token,
|
|
946
|
+
name: tokenName(token),
|
|
947
|
+
dependencies: tokenDependencies(container, providers, token),
|
|
948
|
+
...token === boundStore && {
|
|
949
|
+
cooldownStore: true
|
|
950
|
+
}
|
|
951
|
+
}));
|
|
952
|
+
// Every check has run, and touched only the container: from here the module is made, once none found an error
|
|
953
|
+
stopOnStartupErrors();
|
|
954
|
+
// Every class the module runs, so @UseGuard resolves guards on a direct call to any of them
|
|
955
|
+
for (const cls of [
|
|
956
|
+
...this.options.controllers ?? [],
|
|
957
|
+
...startup.classes
|
|
958
|
+
])Reflect.defineMetadata(META.container, container, cls);
|
|
959
|
+
// And each instance with the module that made it, as another module made from the same classes stamps them again
|
|
960
|
+
recordInstanceContainers(container, [
|
|
961
|
+
...this.options.controllers ?? [],
|
|
962
|
+
...startup.classes
|
|
963
|
+
]);
|
|
964
|
+
prepareThemes(container, appClasses);
|
|
965
|
+
// A handler with no builder is how a fixture is written, so only what is always a mistake is named
|
|
966
|
+
warnUnregisteredCommands(this.options.controllers ?? [], {
|
|
967
|
+
missingBuilders: false
|
|
968
|
+
});
|
|
969
|
+
warnInheritedRoutes(this.options.controllers ?? []);
|
|
970
|
+
warnOverlappingPatterns(this.options.controllers ?? []);
|
|
971
|
+
warnHandlersOffControllers(this.options.controllers ?? [], appClasses);
|
|
972
|
+
if (this.options.app) bindAppPresenter(container, this.options.app);
|
|
973
|
+
// Recorded as the container makes each one, so close() shuts down exactly what exists
|
|
974
|
+
const constructed = new Set();
|
|
975
|
+
for (const { token } of lifecycle){
|
|
976
|
+
container.onActivation(token, (_context, instance)=>{
|
|
977
|
+
constructed.add(token);
|
|
978
|
+
return instance;
|
|
979
|
+
});
|
|
980
|
+
}
|
|
981
|
+
const warnUnanswered = this.options.app && Reflect.getMetadata(META.appOptions, this.options.app)?.warnUnanswered;
|
|
982
|
+
return new TestingModule(container, [
|
|
983
|
+
...this.options.controllers ?? []
|
|
984
|
+
], appClasses, providers, order, messages, lifecycle, constructed, warnUnanswered, services, this.options.shutdownTimeout ?? DEFAULT_SHUTDOWN_TIMEOUT_MS);
|
|
847
985
|
});
|
|
986
|
+
} catch (error) {
|
|
987
|
+
if (isRefusal(error) && !isExplainedError(error)) {
|
|
988
|
+
if (logStartupErrors(error, (text)=>new Logger('TestingModule').error(text), 'the testing module did not compile')) markExplained(error);
|
|
989
|
+
}
|
|
990
|
+
throw error;
|
|
848
991
|
}
|
|
849
|
-
const warnUnanswered = this.options.app && Reflect.getMetadata(META.appOptions, this.options.app)?.warnUnanswered;
|
|
850
|
-
return new TestingModule(container, [
|
|
851
|
-
...this.options.controllers ?? []
|
|
852
|
-
], appClasses, providers, order, messages, lifecycle, constructed, warnUnanswered, services, this.options.shutdownTimeout ?? DEFAULT_SHUTDOWN_TIMEOUT_MS);
|
|
853
992
|
}
|
|
854
993
|
constructor(options, wiring){
|
|
855
994
|
this.options = options;
|
|
@@ -887,7 +1026,7 @@ import { isUserOutcome } from '../common/user-outcome.js';
|
|
|
887
1026
|
* @category Module
|
|
888
1027
|
* @see {@link TestingModuleBuilder}
|
|
889
1028
|
* @see {@link TestingModule}
|
|
890
|
-
* @see {@link https://meocord.dev/docs/4.
|
|
1029
|
+
* @see {@link https://meocord.dev/docs/4.2/testing | The testing module}
|
|
891
1030
|
*/ class MeoCordTestingModule {
|
|
892
1031
|
/**
|
|
893
1032
|
* Starts building a testing module from the classes a test needs.
|
|
@@ -895,7 +1034,15 @@ import { isUserOutcome } from '../common/user-outcome.js';
|
|
|
895
1034
|
* @param options - The controllers, providers, observers and app the module is built from.
|
|
896
1035
|
* @returns The builder, to override what the test replaces before `compile()`.
|
|
897
1036
|
*/ static create(options) {
|
|
898
|
-
|
|
1037
|
+
// A controller listed twice is one controller, as the bot and fromApp take it
|
|
1038
|
+
return new TestingModuleBuilder({
|
|
1039
|
+
...options,
|
|
1040
|
+
...options.controllers && {
|
|
1041
|
+
controllers: [
|
|
1042
|
+
...new Set(options.controllers)
|
|
1043
|
+
]
|
|
1044
|
+
}
|
|
1045
|
+
});
|
|
899
1046
|
}
|
|
900
1047
|
/**
|
|
901
1048
|
* Starts building a testing module from a whole `@MeoCord` app, wired as the bot wires it.
|