meocord 4.2.0 → 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 +264 -0
- package/THIRD_PARTY_NOTICES.md +14 -13
- package/dist/cjs/_shared/{controller.decorator-CRn9kWob.cjs → controller.decorator-XKMlcUZz.cjs} +376 -100
- package/dist/cjs/_shared/{providers-ChvMqnLd.cjs → providers-BZgJyfZS.cjs} +145 -36
- package/dist/cjs/_shared/{sharded-cooldown-store-BdAUTdYs.cjs → sharded-cooldown-store-CsJJyCZd.cjs} +10 -7
- package/dist/cjs/_shared/{startup-roots-CQo8mSP8.cjs → startup-roots-CgfPQrNl.cjs} +219 -63
- package/dist/cjs/_shared/{translate-error-DhDVbT5M.cjs → translate-error-BUAR1WYG.cjs} +10 -5
- package/dist/cjs/common/index.cjs +3 -3
- package/dist/cjs/core/index.cjs +185 -168
- package/dist/cjs/decorator/index.cjs +21 -6
- package/dist/cjs/testing/index.cjs +1594 -286
- package/dist/cli.json +1 -1
- 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 +13 -3
- 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/errors.js +25 -0
- package/dist/esm/common/meocord-text.js +11 -8
- 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 +11 -4
- package/dist/esm/common/sharded-cooldown-store.js +1 -1
- package/dist/esm/common/translator.js +37 -6
- package/dist/esm/core/command-registration.js +93 -24
- package/dist/esm/core/command-routes.js +2 -2
- package/dist/esm/core/cooldown-runner.js +29 -8
- package/dist/esm/core/fallback.js +20 -11
- package/dist/esm/core/guard-runner.js +16 -3
- package/dist/esm/core/handler-input.js +4 -3
- package/dist/esm/core/lifecycle-hooks.js +134 -47
- package/dist/esm/core/meocord-factory.js +55 -32
- package/dist/esm/core/meocord.app.js +116 -75
- package/dist/esm/core/providers.js +7 -2
- package/dist/esm/core/running-calls.js +43 -0
- package/dist/esm/core/shard-context.js +5 -6
- package/dist/esm/core/shard-manager.js +16 -10
- package/dist/esm/core/startup-roots.js +21 -1
- package/dist/esm/core/theme-resolvers.js +4 -1
- package/dist/esm/core/theme-scope.js +3 -1
- package/dist/esm/core/theme-validation.js +17 -10
- package/dist/esm/decorator/controller.decorator.js +56 -20
- package/dist/esm/decorator/cooldown.decorator.js +17 -0
- package/dist/esm/decorator/guard.decorator.js +3 -2
- package/dist/esm/package.json.js +1 -1
- package/dist/esm/testing/catalog.js +26 -10
- package/dist/esm/testing/cooldown-store-suite.js +67 -5
- package/dist/esm/testing/discord-defaults.js +205 -30
- package/dist/esm/testing/meocord-testing-module.js +134 -28
- package/dist/esm/testing/mock-fn.js +107 -26
- package/dist/esm/testing/mock-interaction.js +1026 -172
- package/dist/esm/testing/snowflake.js +9 -2
- package/dist/esm/testing/strict-mocks.js +37 -17
- 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 +2 -2
- package/dist/esm/util/meocord-config-loader.util.js +10 -2
- package/dist/esm/util/meocord-config-validation.util.js +3 -5
- package/dist/esm/util/meocord-source-config.util.js +65 -20
- package/dist/esm/util/method-wrapper.util.js +16 -0
- package/dist/esm/util/platform.util.js +10 -10
- 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 +6 -6
- package/dist/types/common/index.d.ts +6 -6
- package/dist/types/core/index.d.cts +3 -2
- package/dist/types/core/index.d.ts +3 -2
- package/dist/types/decorator/index.d.cts +1 -0
- package/dist/types/decorator/index.d.ts +1 -0
- package/dist/types/{index-CdXsFgjE.d.ts → index-DKJclIi-.d.ts} +45 -21
- package/dist/types/{index-nn5p7OoX.d.cts → index-Dlf0ZZtS.d.cts} +45 -21
- package/dist/types/interface/index.d.cts +1 -1
- package/dist/types/interface/index.d.ts +1 -1
- package/dist/types/{route-BZgFoNeB.d.cts → route-Bh1U13yy.d.cts} +3 -2
- package/dist/types/{route-BZgFoNeB.d.ts → route-Bh1U13yy.d.ts} +3 -2
- package/dist/types/testing/index.d.cts +175 -53
- package/dist/types/testing/index.d.ts +175 -53
- package/dist/types/{theme-resolvers-UKr3-6Ys.d.cts → theme-resolvers-CyEz9pnv.d.cts} +1 -1
- package/dist/types/{theme-resolvers-BcOJVzR9.d.ts → theme-resolvers-DA204O-q.d.ts} +1 -1
- package/dist/types/{translator-DHMkkCpg.d.cts → translator-Bg7hwxDF.d.cts} +21 -8
- package/dist/types/{translator-BViWKtLJ.d.ts → translator-CeP4bCzp.d.ts} +21 -8
- package/package.json +2 -2
|
@@ -6,7 +6,7 @@ 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';
|
|
9
|
+
import { injectedTokens, singletonContextError, recordInstanceContainers } from '../core/guard-runner.js';
|
|
10
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';
|
|
@@ -23,13 +23,13 @@ import { messageCommandHooks } from '../core/message-params.js';
|
|
|
23
23
|
import { appObservers, assertObservers, bindObservers } from '../core/observer-runner.js';
|
|
24
24
|
import { HandlerRegistry } from '../core/handler-registry.js';
|
|
25
25
|
import { adviseInPlaceOfInjecting, meocordClasses } from '../core/meocord-classes.js';
|
|
26
|
-
import { startupClasses, assertStartupClasses, lateGuardCheck } from '../core/startup-roots.js';
|
|
26
|
+
import { startupClasses, assertStartupClasses, lateGuardCheck, stageDependencies } from '../core/startup-roots.js';
|
|
27
27
|
import { markStartupChecked, assertStartupChecked } from '../core/startup-checked.js';
|
|
28
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';
|
|
@@ -38,6 +38,9 @@ import { declaredErrorsOf, collectStartupErrors, startupError, stopOnStartupErro
|
|
|
38
38
|
import { DEFAULT_SHUTDOWN_TIMEOUT_MS, shutdownTimeoutProblem } from '../util/shutdown-timeout.util.js';
|
|
39
39
|
import { resolveProviders, tokenName, bindsOwnToken, providerMap, bindProvider, resolutionOrder, isClassProvider, assertProvided, tokenDependencies } from '../core/providers.js';
|
|
40
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';
|
|
41
44
|
import { META } from '../util/metadata-keys.js';
|
|
42
45
|
import { isUserOutcome } from '../common/user-outcome.js';
|
|
43
46
|
|
|
@@ -110,7 +113,7 @@ import { isUserOutcome } from '../common/user-outcome.js';
|
|
|
110
113
|
await this.resolving;
|
|
111
114
|
if (options.ready) {
|
|
112
115
|
const { client = createMockClient(), primary = true } = options.ready === true ? {} : options.ready;
|
|
113
|
-
this.readying ?? (this.readying = this.runReady(client, primary));
|
|
116
|
+
this.readying ?? (this.readying = this.readyPass.run(()=>this.runReady(client, primary)));
|
|
114
117
|
await this.readying;
|
|
115
118
|
}
|
|
116
119
|
return this;
|
|
@@ -125,12 +128,21 @@ import { isUserOutcome } from '../common/user-outcome.js';
|
|
|
125
128
|
* It first gives up the theme read outside calls, if `init({ ready: true })` made it this module's;
|
|
126
129
|
* reads outside calls then return MeoCord's defaults until another module or app is ready.
|
|
127
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
|
+
*
|
|
128
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
|
|
129
136
|
* `onShutdown`, the calls `invoke`, `dispatch` and `emit` have under way finish first, then the store operations
|
|
130
137
|
* they started, so the store's last writes still reach it. The module stops waiting for the whole sequence after
|
|
131
138
|
* its `shutdownTimeout`, 10 seconds unless set, and logs that it did. Give a test whose fake store never answers, or
|
|
132
139
|
* whose `onShutdown` never settles, a short `shutdownTimeout`.
|
|
133
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
|
+
*
|
|
134
146
|
* @returns Once every hook has run. Rejects with the error of a hook that failed, or an
|
|
135
147
|
* `AggregateError` naming each when several did.
|
|
136
148
|
*
|
|
@@ -145,14 +157,18 @@ import { isUserOutcome } from '../common/user-outcome.js';
|
|
|
145
157
|
* expect(module.get(POOL).ended).toBe(true)
|
|
146
158
|
* ```
|
|
147
159
|
*/ async close() {
|
|
160
|
+
// An onReady awaiting close() would otherwise hold up the close that waits for it
|
|
161
|
+
this.readyPass.excuseCurrent();
|
|
148
162
|
this.closing ?? (this.closing = (async ()=>{
|
|
149
|
-
// A close during init waits for the
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
.
|
|
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) ?? {}));
|
|
153
168
|
const failures = [];
|
|
154
169
|
await runShutdownSequence(this.container, entries, {
|
|
155
170
|
drainCalls: ()=>callsSettled(this.calls),
|
|
171
|
+
readyPass: this.readyPass,
|
|
156
172
|
timeoutMs: this.shutdownTimeout,
|
|
157
173
|
hookFailed: (name, error)=>failures.push({
|
|
158
174
|
name,
|
|
@@ -166,6 +182,11 @@ import { isUserOutcome } from '../common/user-outcome.js';
|
|
|
166
182
|
})());
|
|
167
183
|
await this.closing;
|
|
168
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
|
+
}
|
|
169
190
|
async runReady(client, primary) {
|
|
170
191
|
// As the bot claims it before it comes online, so onReady reads the app's theme
|
|
171
192
|
claimAmbientAppTheme(this.container);
|
|
@@ -181,6 +202,8 @@ import { isUserOutcome } from '../common/user-outcome.js';
|
|
|
181
202
|
}, [], {
|
|
182
203
|
resolveFailed: failed,
|
|
183
204
|
hookFailed: failed
|
|
205
|
+
}, {
|
|
206
|
+
runOnReady: this.readyPass.runOnReady
|
|
184
207
|
});
|
|
185
208
|
throwFailures('onReady', failures);
|
|
186
209
|
}
|
|
@@ -252,7 +275,9 @@ import { isUserOutcome } from '../common/user-outcome.js';
|
|
|
252
275
|
* @returns Whether the handler ran, and the error a filter handled, if any. Rejects with an error no
|
|
253
276
|
* filter handles, or with the error a filter throws: the built-in fallback, which answers such
|
|
254
277
|
* errors in the bot, does not run here. Rejects before running anything with an interaction or a
|
|
255
|
-
* 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.
|
|
256
281
|
*
|
|
257
282
|
* @example
|
|
258
283
|
* ```ts
|
|
@@ -274,6 +299,7 @@ import { isUserOutcome } from '../common/user-outcome.js';
|
|
|
274
299
|
* expect(outcome.error).toBeInstanceOf(GuardDeniedError)
|
|
275
300
|
* ```
|
|
276
301
|
*/ async invoke(controller, methodName, ...args) {
|
|
302
|
+
this.warnIfClosed();
|
|
277
303
|
await this.init();
|
|
278
304
|
if (!this.controllers.includes(controller)) {
|
|
279
305
|
throw new Error(`${controller.name} is not a controller of this testing module. Add it to \`controllers\`.`);
|
|
@@ -303,6 +329,7 @@ import { isUserOutcome } from '../common/user-outcome.js';
|
|
|
303
329
|
hooks = messageCommandHooks(route, params, first, start, given, this.messageOptions.types);
|
|
304
330
|
}
|
|
305
331
|
}
|
|
332
|
+
this.checkArgumentCount(controller, methodName, instance[methodName], first, callArgs.length);
|
|
306
333
|
const presenter = appPresenterOf(this.container);
|
|
307
334
|
// A message command's errors are drawn by the app's presenter too, as an interaction's are
|
|
308
335
|
const client = first instanceof BaseInteraction || first instanceof Message ? first.client : undefined;
|
|
@@ -319,6 +346,19 @@ import { isUserOutcome } from '../common/user-outcome.js';
|
|
|
319
346
|
error
|
|
320
347
|
};
|
|
321
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
|
+
}
|
|
322
362
|
/** Counts `call` as under way until it settles. */ track(call) {
|
|
323
363
|
this.calls.add(call);
|
|
324
364
|
void call.then(()=>this.calls.delete(call), ()=>this.calls.delete(call));
|
|
@@ -364,6 +404,7 @@ import { isUserOutcome } from '../common/user-outcome.js';
|
|
|
364
404
|
return this.dispatcher;
|
|
365
405
|
}
|
|
366
406
|
async dispatch(input, options) {
|
|
407
|
+
this.warnIfClosed();
|
|
367
408
|
await this.init();
|
|
368
409
|
this.registerClient(input);
|
|
369
410
|
const dispatcher = this.dispatcherOf();
|
|
@@ -381,6 +422,7 @@ import { isUserOutcome } from '../common/user-outcome.js';
|
|
|
381
422
|
unhandled: (error)=>unhandled.push(error)
|
|
382
423
|
};
|
|
383
424
|
if (options) {
|
|
425
|
+
noteReactionDispatch(input);
|
|
384
426
|
await this.track(dispatcher.reaction(input, {
|
|
385
427
|
user: options.user,
|
|
386
428
|
action: options.action ?? ReactionHandlerAction.ADD
|
|
@@ -410,15 +452,20 @@ import { isUserOutcome } from '../common/user-outcome.js';
|
|
|
410
452
|
};
|
|
411
453
|
}
|
|
412
454
|
/**
|
|
413
|
-
* Emits a client event to the module's `@On` and `@Once` handlers, through the
|
|
414
|
-
*
|
|
415
|
-
* module's controllers, class providers and their dependencies all receive it. A
|
|
416
|
-
* 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`.
|
|
417
463
|
*
|
|
418
464
|
* @param event - The client event, such as `'guildMemberAdd'`.
|
|
419
465
|
* @param args - The event's arguments, typed from discord.js's `ClientEvents`.
|
|
420
|
-
* @returns How many handlers ran. Rejects once every handler has settled if any threw
|
|
421
|
-
*
|
|
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.
|
|
422
469
|
*
|
|
423
470
|
* @example
|
|
424
471
|
* ```ts
|
|
@@ -430,6 +477,7 @@ import { isUserOutcome } from '../common/user-outcome.js';
|
|
|
430
477
|
* expect(ran).toBe(1)
|
|
431
478
|
* ```
|
|
432
479
|
*/ async emit(event, ...args) {
|
|
480
|
+
this.warnIfClosed();
|
|
433
481
|
await this.init();
|
|
434
482
|
const calls = [];
|
|
435
483
|
for (const cls of this.eventClasses){
|
|
@@ -474,7 +522,10 @@ import { isUserOutcome } from '../common/user-outcome.js';
|
|
|
474
522
|
this.appWarnUnanswered = appWarnUnanswered;
|
|
475
523
|
this.services = services;
|
|
476
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;
|
|
477
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();
|
|
478
529
|
/** The calls under way, each removed once it settles: what `close()` lets finish before the cooldown store stops. */ this.calls = new Set();
|
|
479
530
|
}
|
|
480
531
|
}
|
|
@@ -539,6 +590,10 @@ import { isUserOutcome } from '../common/user-outcome.js';
|
|
|
539
590
|
/**
|
|
540
591
|
* Replaces a guard with a stub wherever it applies, globally or on a controller or handler.
|
|
541
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
|
+
*
|
|
542
597
|
* @param guard - The guard class to replace.
|
|
543
598
|
* @example
|
|
544
599
|
* ```ts
|
|
@@ -556,6 +611,10 @@ import { isUserOutcome } from '../common/user-outcome.js';
|
|
|
556
611
|
* Replaces an interceptor with a stub wherever it applies, globally or on a controller or handler.
|
|
557
612
|
* The stub's `intercept` receives the context and `next`; call `next.handle()` to run the handler.
|
|
558
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
|
+
*
|
|
559
618
|
* @param interceptor - The interceptor class to replace.
|
|
560
619
|
* @example
|
|
561
620
|
* ```ts
|
|
@@ -573,6 +632,10 @@ import { isUserOutcome } from '../common/user-outcome.js';
|
|
|
573
632
|
* Replaces an exception filter with a stub wherever it applies. The filter's `@Catch` still decides
|
|
574
633
|
* which errors reach the stub.
|
|
575
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
|
+
*
|
|
576
639
|
* @param filter - The filter class to replace.
|
|
577
640
|
* @example
|
|
578
641
|
* ```ts
|
|
@@ -705,6 +768,39 @@ import { isUserOutcome } from '../common/user-outcome.js';
|
|
|
705
768
|
const providers = providerMap(this.wiring?.providers ?? [], '@MeoCord({ providers })');
|
|
706
769
|
for (const [token, provider] of providerMap(this.options.providers ?? [], "the testing module's providers"))providers.set(token, provider);
|
|
707
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
|
+
}
|
|
708
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
|
|
709
805
|
stopOnStartupErrors();
|
|
710
806
|
const services = this.wiring?.services ?? [];
|
|
@@ -716,12 +812,13 @@ import { isUserOutcome } from '../common/user-outcome.js';
|
|
|
716
812
|
const themeResolver = themeResolverClass(stages?.themeFor?.resolvers);
|
|
717
813
|
// Every class the module runs, as the app finds them, except stage classes an override stands in for
|
|
718
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;
|
|
719
816
|
const startup = startupClasses({
|
|
720
817
|
controllers: this.options.controllers ?? [],
|
|
721
818
|
services,
|
|
722
819
|
providers,
|
|
723
820
|
stages,
|
|
724
|
-
presenter:
|
|
821
|
+
presenter: appPresenterClass,
|
|
725
822
|
cooldownStore: store,
|
|
726
823
|
themeFor: stages?.themeFor?.resolvers,
|
|
727
824
|
observers: [
|
|
@@ -752,16 +849,8 @@ import { isUserOutcome } from '../common/user-outcome.js';
|
|
|
752
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() }.');
|
|
753
850
|
});
|
|
754
851
|
}
|
|
755
|
-
//
|
|
756
|
-
for (const [
|
|
757
|
-
container.bind(guardClass).toConstantValue(stub);
|
|
758
|
-
}
|
|
759
|
-
for (const [filterClass, stub] of this.filterOverrides){
|
|
760
|
-
container.bind(filterClass).toConstantValue(stub);
|
|
761
|
-
}
|
|
762
|
-
for (const [interceptorClass, stub] of this.interceptorOverrides){
|
|
763
|
-
container.bind(interceptorClass).toConstantValue(stub);
|
|
764
|
-
}
|
|
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);
|
|
765
854
|
// The app's cooldown policy, so a test of a failing store sees what the bot would do
|
|
766
855
|
if (appOptions && !container.isBound(COOLDOWN_POLICY)) container.bind(COOLDOWN_POLICY).toConstantValue(cooldownPolicyFrom(appOptions));
|
|
767
856
|
// The app's translator, unless a provider stands in for it
|
|
@@ -835,6 +924,9 @@ import { isUserOutcome } from '../common/user-outcome.js';
|
|
|
835
924
|
bindObservers(container, observers);
|
|
836
925
|
// The store calls ask: the test's own when it provides CooldownStore, else the app's
|
|
837
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);
|
|
838
930
|
// The order the app runs lifecycle hooks in: its cooldown store, then providers, controllers and observers, each
|
|
839
931
|
// after what it injects
|
|
840
932
|
const lifecycle = resolutionOrder(container, providers, [
|
|
@@ -843,6 +935,7 @@ import { isUserOutcome } from '../common/user-outcome.js';
|
|
|
843
935
|
] : [],
|
|
844
936
|
...providers.keys(),
|
|
845
937
|
...services,
|
|
938
|
+
...stageServices,
|
|
846
939
|
...themeResolver ? [
|
|
847
940
|
themeResolver
|
|
848
941
|
] : [],
|
|
@@ -863,6 +956,11 @@ import { isUserOutcome } from '../common/user-outcome.js';
|
|
|
863
956
|
...this.options.controllers ?? [],
|
|
864
957
|
...startup.classes
|
|
865
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
|
+
]);
|
|
866
964
|
prepareThemes(container, appClasses);
|
|
867
965
|
// A handler with no builder is how a fixture is written, so only what is always a mistake is named
|
|
868
966
|
warnUnregisteredCommands(this.options.controllers ?? [], {
|
|
@@ -936,7 +1034,15 @@ import { isUserOutcome } from '../common/user-outcome.js';
|
|
|
936
1034
|
* @param options - The controllers, providers, observers and app the module is built from.
|
|
937
1035
|
* @returns The builder, to override what the test replaces before `compile()`.
|
|
938
1036
|
*/ static create(options) {
|
|
939
|
-
|
|
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
|
+
});
|
|
940
1046
|
}
|
|
941
1047
|
/**
|
|
942
1048
|
* Starts building a testing module from a whole `@MeoCord` app, wired as the bot wires it.
|
|
@@ -1,7 +1,7 @@
|
|
|
1
|
+
import { shared } from '../util/shared-state.util.js';
|
|
2
|
+
|
|
1
3
|
// Framework-agnostic mock function, so `meocord/testing` depends on neither jest nor vitest.
|
|
2
4
|
// Both detect it through `_isMockFunction` and read `fn.mock.calls` in their assertions.
|
|
3
|
-
// Each call signature of `T`, up to four, as a union: `Parameters` and `ReturnType` read only an overloaded function's
|
|
4
|
-
// last signature, such as the list form of a manager's `fetch`
|
|
5
5
|
/**
|
|
6
6
|
* Tells whether a value is a mock function: one from `meocord/testing`, `jest.fn()` or `vi.fn()`.
|
|
7
7
|
*
|
|
@@ -24,13 +24,20 @@
|
|
|
24
24
|
*/ function isMockFunction(fn) {
|
|
25
25
|
return typeof fn === 'function' && '_isMockFunction' in fn && fn._isMockFunction === true;
|
|
26
26
|
}
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
27
|
+
/**
|
|
28
|
+
* What `useMockFn` set and the mocks made, kept for both builds of this meocord version, so a setup file in one module
|
|
29
|
+
* format sets them for specs in the other:
|
|
30
|
+
* - `invocationOrder`: when each call of meocord's own mocks came, across all of them, as Vitest numbers its own;
|
|
31
|
+
* - `created`: every mock createMockFn made, held weakly so the mocks of a finished test can still be collected;
|
|
32
|
+
* - `runnerFactory` and `restoreImpl`: the runner's factory useMockFn registered, and for each of its mocks how to
|
|
33
|
+
* give back the implementation it was created with, through the runner's own setter, which a mock's behaviour may wrap.
|
|
34
|
+
*/ const mocks = shared('mocks', ()=>({
|
|
35
|
+
invocationOrder: 0,
|
|
36
|
+
created: new Set(),
|
|
37
|
+
runnerFactory: undefined,
|
|
38
|
+
restoreImpl: new WeakMap()
|
|
39
|
+
}));
|
|
40
|
+
const collected = new FinalizationRegistry((ref)=>mocks.created.delete(ref));
|
|
34
41
|
/**
|
|
35
42
|
* Makes every mock `meocord/testing` creates with the test runner's own mock function, such as Vitest's `vi.fn`.
|
|
36
43
|
*
|
|
@@ -61,26 +68,36 @@ const restoreImpl = new WeakMap();
|
|
|
61
68
|
* @category Mocks
|
|
62
69
|
* @see {@link createMockFn}
|
|
63
70
|
*/ function useMockFn(factory) {
|
|
64
|
-
if (factory === runnerFactory) return;
|
|
71
|
+
if (factory === mocks.runnerFactory) return;
|
|
65
72
|
if ([
|
|
66
|
-
...created
|
|
73
|
+
...mocks.created
|
|
67
74
|
].some((ref)=>ref.deref() !== undefined)) {
|
|
68
75
|
throw new Error("useMockFn() goes before any mock is made: a mock made before it keeps meocord's own function, and the two kinds " + "would mix. Call it once, in the test runner's setup file.");
|
|
69
76
|
}
|
|
70
|
-
const
|
|
77
|
+
const refusal = (cause)=>new Error("useMockFn() takes a function that makes mocks with jest's and Vitest's API, such as vi.fn, jest.fn or bun's " + "mock. node:test's mock.fn records calls in its own shape, so under node:test leave meocord's own mock function " + 'in place.', cause === undefined ? undefined : {
|
|
78
|
+
cause
|
|
79
|
+
});
|
|
80
|
+
let probe;
|
|
81
|
+
try {
|
|
82
|
+
probe = factory();
|
|
83
|
+
} catch (error) {
|
|
84
|
+
// node:test's mock.fn, given on its own, throws for want of its tracker
|
|
85
|
+
throw refusal(error);
|
|
86
|
+
}
|
|
71
87
|
const api = [
|
|
72
88
|
'mockImplementation',
|
|
73
89
|
'mockClear',
|
|
74
90
|
'mockReset'
|
|
75
91
|
];
|
|
76
92
|
if (!isMockFunction(probe) || !Array.isArray(probe.mock?.calls) || api.some((name)=>typeof probe[name] !== 'function')) {
|
|
77
|
-
throw
|
|
93
|
+
throw refusal();
|
|
78
94
|
}
|
|
79
|
-
runnerFactory = factory;
|
|
95
|
+
mocks.runnerFactory = factory;
|
|
80
96
|
}
|
|
97
|
+
/** Whether mocks are made with a runner's function, which {@link useMockFn} registered. */ const usesRunnerMockFn = ()=>mocks.runnerFactory !== undefined;
|
|
81
98
|
/** Tracks a mock for clearAllMocks and resetAllMocks. */ function track(mockFn) {
|
|
82
99
|
const ref = new WeakRef(mockFn);
|
|
83
|
-
created.add(ref);
|
|
100
|
+
mocks.created.add(ref);
|
|
84
101
|
collected.register(mockFn, ref);
|
|
85
102
|
return mockFn;
|
|
86
103
|
}
|
|
@@ -103,7 +120,7 @@ const restoreImpl = new WeakMap();
|
|
|
103
120
|
* @category Mocks
|
|
104
121
|
* @see {@link resetAllMocks}
|
|
105
122
|
*/ function clearAllMocks() {
|
|
106
|
-
for (const ref of created)ref.deref()?.mockClear();
|
|
123
|
+
for (const ref of mocks.created)ref.deref()?.mockClear();
|
|
107
124
|
}
|
|
108
125
|
/**
|
|
109
126
|
* Clears every mock from `meocord/testing`, and puts each back to the implementation it was created with.
|
|
@@ -126,12 +143,12 @@ const restoreImpl = new WeakMap();
|
|
|
126
143
|
* @category Mocks
|
|
127
144
|
* @see {@link clearAllMocks}
|
|
128
145
|
*/ function resetAllMocks() {
|
|
129
|
-
for (const ref of created){
|
|
146
|
+
for (const ref of mocks.created){
|
|
130
147
|
const mockFn = ref.deref();
|
|
131
148
|
if (!mockFn) continue;
|
|
132
149
|
mockFn.mockReset();
|
|
133
150
|
// A runner whose reset drops the implementation the mock was created with, as jest's and bun's do, gets it back
|
|
134
|
-
restoreImpl.get(mockFn)?.();
|
|
151
|
+
mocks.restoreImpl.get(mockFn)?.();
|
|
135
152
|
}
|
|
136
153
|
}
|
|
137
154
|
/**
|
|
@@ -145,6 +162,14 @@ const restoreImpl = new WeakMap();
|
|
|
145
162
|
* `mockReturnValue`, `mockResolvedValue`, `mockRejectedValue` and `mockImplementation` share one implementation, so
|
|
146
163
|
* the last one set wins. The `*Once` variants queue, and run first, in the order they were set.
|
|
147
164
|
*
|
|
165
|
+
* It records what Vitest's `toHaveResolved` and `toHaveBeenCalledBefore` read as well. Its call order is counted
|
|
166
|
+
* across meocord's own mocks, apart from the runner's, so compare the order of two of meocord's mocks, or of two of the
|
|
167
|
+
* runner's, not one of each.
|
|
168
|
+
*
|
|
169
|
+
* A promise the implementation returns is handed back as one that settles the same way, not as the same object: test
|
|
170
|
+
* it with `toEqual`, `toHaveReturnedWith` or by awaiting it, not with `toBe`. A property set on the given promise, such
|
|
171
|
+
* as a `cancel` method, isn't carried over.
|
|
172
|
+
*
|
|
148
173
|
* @param impl - The implementation to run until another is set.
|
|
149
174
|
*
|
|
150
175
|
* @example
|
|
@@ -162,11 +187,12 @@ const restoreImpl = new WeakMap();
|
|
|
162
187
|
* @category Mocks
|
|
163
188
|
* @see {@link MockInstance}
|
|
164
189
|
*/ function createMockFn(impl) {
|
|
190
|
+
const { runnerFactory } = mocks;
|
|
165
191
|
if (runnerFactory) {
|
|
166
192
|
const runnerMock = runnerFactory(impl);
|
|
167
193
|
if (impl) {
|
|
168
194
|
const setImpl = runnerMock.mockImplementation.bind(runnerMock);
|
|
169
|
-
restoreImpl.set(runnerMock, ()=>setImpl(impl));
|
|
195
|
+
mocks.restoreImpl.set(runnerMock, ()=>setImpl(impl));
|
|
170
196
|
}
|
|
171
197
|
return track(runnerMock);
|
|
172
198
|
}
|
|
@@ -179,9 +205,20 @@ const restoreImpl = new WeakMap();
|
|
|
179
205
|
const calls = [];
|
|
180
206
|
const results = [];
|
|
181
207
|
const instances = [];
|
|
208
|
+
const settledResults = [];
|
|
209
|
+
const invocationCallOrder = [];
|
|
210
|
+
const contexts = [];
|
|
182
211
|
const mockFn = function(...args) {
|
|
183
212
|
calls.push(args);
|
|
184
213
|
instances.push(this);
|
|
214
|
+
contexts.push(this);
|
|
215
|
+
invocationCallOrder.push(++mocks.invocationOrder);
|
|
216
|
+
// Updated in place as the call settles, so a promise's outcome shows once it resolves or rejects
|
|
217
|
+
const settled = {
|
|
218
|
+
type: 'incomplete',
|
|
219
|
+
value: undefined
|
|
220
|
+
};
|
|
221
|
+
settledResults.push(settled);
|
|
185
222
|
let type = 'return';
|
|
186
223
|
let value;
|
|
187
224
|
try {
|
|
@@ -192,10 +229,36 @@ const restoreImpl = new WeakMap();
|
|
|
192
229
|
} else {
|
|
193
230
|
value = undefined;
|
|
194
231
|
}
|
|
232
|
+
if (value instanceof Promise) {
|
|
233
|
+
// Returned in its place, so a rejection nobody awaits stays unhandled in the test, as it is on a bot: a handler
|
|
234
|
+
// on the implementation's own promise would mark it handled
|
|
235
|
+
value = value.then((resolved)=>{
|
|
236
|
+
Object.assign(settled, {
|
|
237
|
+
type: 'fulfilled',
|
|
238
|
+
value: resolved
|
|
239
|
+
});
|
|
240
|
+
return resolved;
|
|
241
|
+
}, (rejected)=>{
|
|
242
|
+
Object.assign(settled, {
|
|
243
|
+
type: 'rejected',
|
|
244
|
+
value: rejected
|
|
245
|
+
});
|
|
246
|
+
throw rejected;
|
|
247
|
+
});
|
|
248
|
+
} else {
|
|
249
|
+
Object.assign(settled, {
|
|
250
|
+
type: 'fulfilled',
|
|
251
|
+
value
|
|
252
|
+
});
|
|
253
|
+
}
|
|
195
254
|
return value;
|
|
196
255
|
} catch (err) {
|
|
197
256
|
type = 'throw';
|
|
198
257
|
value = err;
|
|
258
|
+
Object.assign(settled, {
|
|
259
|
+
type: 'rejected',
|
|
260
|
+
value: err
|
|
261
|
+
});
|
|
199
262
|
throw err;
|
|
200
263
|
} finally{
|
|
201
264
|
results.push({
|
|
@@ -223,6 +286,18 @@ const restoreImpl = new WeakMap();
|
|
|
223
286
|
return calls.length > 0 ? calls[calls.length - 1] : undefined;
|
|
224
287
|
}
|
|
225
288
|
};
|
|
289
|
+
// Out of the mock's keys, so a test comparing `mock` as a whole sees the shape it always had
|
|
290
|
+
Object.defineProperties(mock, {
|
|
291
|
+
settledResults: {
|
|
292
|
+
get: ()=>settledResults
|
|
293
|
+
},
|
|
294
|
+
invocationCallOrder: {
|
|
295
|
+
get: ()=>invocationCallOrder
|
|
296
|
+
},
|
|
297
|
+
contexts: {
|
|
298
|
+
get: ()=>contexts
|
|
299
|
+
}
|
|
300
|
+
});
|
|
226
301
|
Object.defineProperty(mockFn, 'mock', {
|
|
227
302
|
value: mock,
|
|
228
303
|
enumerable: false
|
|
@@ -259,16 +334,22 @@ const restoreImpl = new WeakMap();
|
|
|
259
334
|
onceQueue.push(fn);
|
|
260
335
|
return mockFn;
|
|
261
336
|
};
|
|
337
|
+
const forgetCalls = ()=>{
|
|
338
|
+
for (const record of [
|
|
339
|
+
calls,
|
|
340
|
+
results,
|
|
341
|
+
instances,
|
|
342
|
+
settledResults,
|
|
343
|
+
invocationCallOrder,
|
|
344
|
+
contexts
|
|
345
|
+
])record.length = 0;
|
|
346
|
+
};
|
|
262
347
|
mockFn.mockClear = ()=>{
|
|
263
|
-
|
|
264
|
-
results.length = 0;
|
|
265
|
-
instances.length = 0;
|
|
348
|
+
forgetCalls();
|
|
266
349
|
return mockFn;
|
|
267
350
|
};
|
|
268
351
|
mockFn.mockReset = ()=>{
|
|
269
|
-
|
|
270
|
-
results.length = 0;
|
|
271
|
-
instances.length = 0;
|
|
352
|
+
forgetCalls();
|
|
272
353
|
onceQueue = [];
|
|
273
354
|
currentImpl = impl;
|
|
274
355
|
return mockFn;
|
|
@@ -285,4 +366,4 @@ const restoreImpl = new WeakMap();
|
|
|
285
366
|
return track(mockFn);
|
|
286
367
|
}
|
|
287
368
|
|
|
288
|
-
export { clearAllMocks, createMockFn, isMockFunction, resetAllMocks, useMockFn };
|
|
369
|
+
export { clearAllMocks, createMockFn, isMockFunction, resetAllMocks, useMockFn, usesRunnerMockFn };
|