bazis 0.97.11 → 0.98.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/core/app/runApp.ts +10 -5
- package/core/di/index.ts +1 -0
- package/core/di/module/createContainer.ts +40 -3
- package/core/di/module/index.ts +1 -1
- package/core/generatedRuntime.ts +6 -4
- package/core/http/Binding/ParameterBinder.ts +16 -1
- package/core/http/Binding/autoBindings.ts +1 -1
- package/core/http/Binding/bindings.ts +2 -0
- package/core/http/Binding/generatedSpec.ts +2 -0
- package/core/http/OpenApi/openApiDocument.ts +2 -2
- package/core/http/Routing/Router.ts +14 -1
- package/core/infra/connectors/codex/CodexAppServerClient.ts +1 -1
- package/core/kernel/KernelBuilder.ts +12 -2
- package/core/scripts/di-generate.ts +18 -0
- package/core/testing/index.ts +75 -0
- package/package.json +2 -1
package/core/app/runApp.ts
CHANGED
|
@@ -238,9 +238,16 @@ function normalizeAbsolutePath(path: string): string {
|
|
|
238
238
|
* The root module stays clean: `@Module({ imports: [UsersModule] })`.
|
|
239
239
|
*/
|
|
240
240
|
export async function runApp(root: BazisModuleRef, options: RunAppOptions = {}): Promise<number> {
|
|
241
|
-
const configure = options.configure;
|
|
242
241
|
await loadBazisGeneratedRuntime();
|
|
242
|
+
return Bazis.run(composeApp(root, options), options.kernel, options.configure);
|
|
243
|
+
}
|
|
243
244
|
|
|
245
|
+
/**
|
|
246
|
+
* @internal The application root `runApp` starts: infrastructure, cache and
|
|
247
|
+
* configs next to the feature root, wrapped in the HTTP/gRPC/UI hosts the
|
|
248
|
+
* options ask for. Shared with `startTestApp`, so tests run the same graph.
|
|
249
|
+
*/
|
|
250
|
+
export function composeApp(root: BazisModuleRef, options: RunAppOptions = {}): BazisModuleRef {
|
|
244
251
|
// Infrastructure and cache are global modules; add them to the graph next to the feature root.
|
|
245
252
|
const globals: BazisModuleRef[] = [];
|
|
246
253
|
if (options.infra !== undefined) {
|
|
@@ -277,17 +284,15 @@ export async function runApp(root: BazisModuleRef, options: RunAppOptions = {}):
|
|
|
277
284
|
validator,
|
|
278
285
|
imports: [httpAppRoot],
|
|
279
286
|
});
|
|
280
|
-
|
|
287
|
+
return options.grpc === undefined ? httpRoot : {
|
|
281
288
|
imports: [httpRoot, grpcModule({ ...options.grpc, validator: options.grpc.validator === undefined ? validator : options.grpc.validator, imports: [appRoot, ...(options.grpc.imports ?? [])] })],
|
|
282
289
|
};
|
|
283
|
-
return Bazis.run(runtimeRoot, options.kernel, configure);
|
|
284
290
|
}
|
|
285
291
|
|
|
286
292
|
if (options.validator) {
|
|
287
293
|
useModelValidator(options.validator);
|
|
288
294
|
}
|
|
289
295
|
const runtimeRoot = withUiRuntime(appRoot, options.ui, undefined);
|
|
290
|
-
|
|
296
|
+
return options.grpc === undefined ? runtimeRoot
|
|
291
297
|
: grpcModule({ ...options.grpc, validator: options.grpc.validator === undefined ? options.validator ?? modelValidatorAdapter : options.grpc.validator, imports: [runtimeRoot, ...(options.grpc.imports ?? [])] });
|
|
292
|
-
return Bazis.run(transportRoot, options.kernel, configure);
|
|
293
298
|
}
|
package/core/di/index.ts
CHANGED
|
@@ -4,9 +4,9 @@ import { HOSTED_SERVICE, type HostedService } from "../extensions/hosted-service
|
|
|
4
4
|
import { SERVICE_PROVIDER, SERVICE_PROVIDER_BY_TYPE } from "../extensions/service-provider-token";
|
|
5
5
|
import { isClassProvider, ProviderDefinition, type Provider } from "../provider";
|
|
6
6
|
import { getClassDeps } from "../internal/classDeps";
|
|
7
|
-
import { ModuleEncapsulationError } from "../errors";
|
|
7
|
+
import { DiError, ModuleEncapsulationError } from "../errors";
|
|
8
8
|
import { OpenGenericRegistration } from "../internal/OpenGenericRegistration";
|
|
9
|
-
import type
|
|
9
|
+
import { tokenToDebugName, type Class } from "../token";
|
|
10
10
|
import type { BuildServiceProviderOptions } from "../types";
|
|
11
11
|
import { DI } from "./DI";
|
|
12
12
|
import { Global } from "./Global";
|
|
@@ -36,7 +36,17 @@ function moduleDiagnosticName(moduleRef: BazisModuleRef, fallbackIndex: number):
|
|
|
36
36
|
return `module#${fallbackIndex}`;
|
|
37
37
|
}
|
|
38
38
|
|
|
39
|
-
export
|
|
39
|
+
export interface CreateContainerOptions extends BuildServiceProviderOptions {
|
|
40
|
+
/**
|
|
41
|
+
* Replacements for registered providers, for tests: `singleton(ITaskStore,
|
|
42
|
+
* FakeStore)`. They are registered after the whole module graph, so they win
|
|
43
|
+
* regardless of `imports` order, and they are visible to every module. Each
|
|
44
|
+
* one must replace an existing registration of its token (and key).
|
|
45
|
+
*/
|
|
46
|
+
readonly overrides?: readonly ProviderDefinition[];
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function createContainer(rootModule: BazisModuleRef, options?: CreateContainerOptions): DiContainer {
|
|
40
50
|
// The container itself is injectable (SERVICE_PROVIDER, .NET-style). The
|
|
41
51
|
// instance does not exist until the end of this function, so the factory
|
|
42
52
|
// closes over a ref that is assigned right after construction.
|
|
@@ -187,6 +197,9 @@ export function createContainer(rootModule: BazisModuleRef, options?: BuildServi
|
|
|
187
197
|
|
|
188
198
|
loadModule(diInfraModule);
|
|
189
199
|
loadModule(rootModule);
|
|
200
|
+
if (options?.overrides !== undefined && options.overrides.length > 0) {
|
|
201
|
+
loadModule(overridesModule(rootModule, options.overrides, collection.toArray()));
|
|
202
|
+
}
|
|
190
203
|
const recordList = Array.from(records.values());
|
|
191
204
|
const collectedDefinitions = collection.toArray();
|
|
192
205
|
const classProviderHooks = collectClassProviderHooks(collectedDefinitions);
|
|
@@ -247,6 +260,30 @@ export function createContainer(rootModule: BazisModuleRef, options?: BuildServi
|
|
|
247
260
|
return built;
|
|
248
261
|
}
|
|
249
262
|
|
|
263
|
+
/**
|
|
264
|
+
* A global module loaded last: its registrations win and are exported to every
|
|
265
|
+
* module. It imports the root, so a replacement may use the root's exports.
|
|
266
|
+
*/
|
|
267
|
+
function overridesModule(
|
|
268
|
+
rootModule: BazisModuleRef,
|
|
269
|
+
overrides: readonly ProviderDefinition[],
|
|
270
|
+
registered: readonly ProviderDefinition[],
|
|
271
|
+
): BazisModuleRef {
|
|
272
|
+
for (const override of overrides) {
|
|
273
|
+
const token = override.provider.provide;
|
|
274
|
+
if (!registered.some((definition) => definition.provider.provide === token && definition.key === override.key)) {
|
|
275
|
+
const subject = override.key === undefined ? `"${tokenToDebugName(token)}"` : `"${tokenToDebugName(token)}" with key ${JSON.stringify(String(override.key))}`;
|
|
276
|
+
throw new DiError(`Override of ${subject} replaces nothing: no module registers it. Check the token, or register it in a module first.`);
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
return {
|
|
280
|
+
imports: [rootModule],
|
|
281
|
+
providers: overrides,
|
|
282
|
+
exports: [...new Set(overrides.map((override) => override.provider.provide))],
|
|
283
|
+
global: true,
|
|
284
|
+
};
|
|
285
|
+
}
|
|
286
|
+
|
|
250
287
|
function normalizeLateGeneratedClassDeps(definition: ProviderDefinition): ProviderDefinition {
|
|
251
288
|
const provider = definition.provider;
|
|
252
289
|
if (!isClassProvider(provider) || provider.deps !== undefined || getClassDeps(provider.useClass) !== undefined) {
|
package/core/di/module/index.ts
CHANGED
|
@@ -25,7 +25,7 @@ export {
|
|
|
25
25
|
type ModuleOwnedProviderContributionContext,
|
|
26
26
|
type ModuleOwnedProviderContributor,
|
|
27
27
|
} from "./moduleOwnedProviderContributors";
|
|
28
|
-
export { createContainer } from "./createContainer";
|
|
28
|
+
export { createContainer, type CreateContainerOptions } from "./createContainer";
|
|
29
29
|
export { collectModuleControllers } from "./collectModuleControllers";
|
|
30
30
|
export { collectModuleConfigs } from "./collectModuleConfigs";
|
|
31
31
|
export { collectModuleUiProfiles } from "./collectModuleUiProfiles";
|
package/core/generatedRuntime.ts
CHANGED
|
@@ -165,12 +165,14 @@ async function loadRuntimeOnce(): Promise<void> {
|
|
|
165
165
|
|
|
166
166
|
/**
|
|
167
167
|
* The project's generated module `name`, or undefined when it does not exist.
|
|
168
|
-
*
|
|
169
|
-
*
|
|
170
|
-
*
|
|
168
|
+
* The project root is the working directory, as for `bazis dev` and `bazis
|
|
169
|
+
* test`; it comes first, because a framework linked from another checkout
|
|
170
|
+
* (`bazis new --link-framework`) would otherwise find that checkout's own
|
|
171
|
+
* `src/generated` next to its sources. Framework sources inside the project
|
|
172
|
+
* (`src/bazis`) find the same directory either way.
|
|
171
173
|
*/
|
|
172
174
|
async function importGeneratedModule<T>(name: string): Promise<T | undefined> {
|
|
173
|
-
const candidates = [
|
|
175
|
+
const candidates = [path.join(process.cwd(), "src/generated/bazis", name), `../../generated/bazis/${name}`];
|
|
174
176
|
for (const candidate of candidates) {
|
|
175
177
|
try {
|
|
176
178
|
return (await import(candidate)) as T;
|
|
@@ -24,6 +24,21 @@ function bindValue(binding: ParameterBinding, raw: string | undefined, kind: str
|
|
|
24
24
|
return binding.type && binding.type !== "string" ? convertOr400(raw, binding.type, binding.name ?? "?") : raw;
|
|
25
25
|
}
|
|
26
26
|
|
|
27
|
+
/**
|
|
28
|
+
* `?tag=a&tag=b` → `["a", "b"]`, each value converted by the element type.
|
|
29
|
+
* Without the parameter: the default, `undefined` for `tag?: string[]`, or `[]`.
|
|
30
|
+
*/
|
|
31
|
+
function bindArray(binding: ParameterBinding, ctx: HttpContext): unknown {
|
|
32
|
+
const raw = ctx.url.searchParams.getAll(binding.name ?? "");
|
|
33
|
+
if (raw.length === 0) {
|
|
34
|
+
if (binding.defaultValue !== undefined) return binding.defaultValue;
|
|
35
|
+
return binding.optional ? undefined : [];
|
|
36
|
+
}
|
|
37
|
+
return binding.type && binding.type !== "string"
|
|
38
|
+
? raw.map((value) => convertOr400(value, binding.type!, binding.name ?? "?"))
|
|
39
|
+
: raw;
|
|
40
|
+
}
|
|
41
|
+
|
|
27
42
|
/**
|
|
28
43
|
* Resolves generated binding descriptors into the action's argument list.
|
|
29
44
|
* All conversions fail with 400 (never 500); body parse errors and model
|
|
@@ -52,7 +67,7 @@ export async function bindArguments(
|
|
|
52
67
|
break;
|
|
53
68
|
}
|
|
54
69
|
case "query":
|
|
55
|
-
args[index] = bindValue(binding, ctx.query(binding.name ?? ""), "query");
|
|
70
|
+
args[index] = binding.array ? bindArray(binding, ctx) : bindValue(binding, ctx.query(binding.name ?? ""), "query");
|
|
56
71
|
break;
|
|
57
72
|
case "body": {
|
|
58
73
|
const contentType = ctx.header("content-type") ?? "";
|
|
@@ -55,7 +55,7 @@ function toBinding(spec: GeneratedBindingSpec, controllerName: string, methodNam
|
|
|
55
55
|
case "route":
|
|
56
56
|
return { source: "route", name: spec.name, type: spec.type, optional: spec.optional };
|
|
57
57
|
case "query":
|
|
58
|
-
return { source: "query", name: spec.name, type: spec.type, optional: spec.optional };
|
|
58
|
+
return { source: "query", name: spec.name, type: spec.type, optional: spec.optional, array: spec.array };
|
|
59
59
|
case "context":
|
|
60
60
|
return { source: "context" };
|
|
61
61
|
case "request":
|
|
@@ -11,6 +11,8 @@ export interface ParameterBinding {
|
|
|
11
11
|
readonly type?: ValueType;
|
|
12
12
|
readonly defaultValue?: unknown;
|
|
13
13
|
readonly optional?: boolean;
|
|
14
|
+
/** Query array: all values of the name (`?tag=a&tag=b`), each converted by `type`. */
|
|
15
|
+
readonly array?: boolean;
|
|
14
16
|
/** Body model class (instantiated, sanitized and validated). */
|
|
15
17
|
readonly model?: Class<object>;
|
|
16
18
|
/** Whitelist/limits for the class-based list binding (JSON:API list query). */
|
|
@@ -13,6 +13,8 @@ export interface GeneratedBindingSpec {
|
|
|
13
13
|
readonly type?: "int" | "number" | "bool" | "string";
|
|
14
14
|
/** The parameter is declared with `?` or a default value. */
|
|
15
15
|
readonly optional?: boolean;
|
|
16
|
+
/** A query array (`tag: string[]`): every `?tag=` value, each converted by `type`. */
|
|
17
|
+
readonly array?: boolean;
|
|
16
18
|
/** DTO class name (resolved through the generated target; `@RequestModel()` classes are the fallback). */
|
|
17
19
|
readonly model?: string;
|
|
18
20
|
}
|
|
@@ -173,8 +173,8 @@ function collectParameters(
|
|
|
173
173
|
parameters.push({
|
|
174
174
|
name: binding.name,
|
|
175
175
|
in: "query",
|
|
176
|
-
required: binding.optional !== true && binding.defaultValue === undefined,
|
|
177
|
-
schema: schemaForValueType(binding.type),
|
|
176
|
+
required: binding.array !== true && binding.optional !== true && binding.defaultValue === undefined,
|
|
177
|
+
schema: binding.array ? { type: "array", items: schemaForValueType(binding.type) } : schemaForValueType(binding.type),
|
|
178
178
|
});
|
|
179
179
|
}
|
|
180
180
|
break;
|
|
@@ -17,6 +17,8 @@ export interface RouteAction {
|
|
|
17
17
|
|
|
18
18
|
interface RouteEntry {
|
|
19
19
|
readonly action: RouteAction;
|
|
20
|
+
/** The registered path, for diagnostics: `/tasks/:id`. */
|
|
21
|
+
readonly template: string;
|
|
20
22
|
/** Names belong to the selected declaration, never to shared tree edges. */
|
|
21
23
|
readonly bindings: readonly { readonly name: string; readonly index: number }[];
|
|
22
24
|
}
|
|
@@ -58,6 +60,7 @@ export class Router {
|
|
|
58
60
|
register(segments: readonly TemplateSegment[], httpMethod: string, versionKey: string, action: RouteAction): void {
|
|
59
61
|
const entry: RouteEntry = {
|
|
60
62
|
action,
|
|
63
|
+
template: renderTemplate(segments),
|
|
61
64
|
bindings: segments.flatMap((segment, index) => segment.kind === "static" ? [] : [{ name: segment.name, index }]),
|
|
62
65
|
};
|
|
63
66
|
let node = this.root;
|
|
@@ -101,8 +104,11 @@ export class Router {
|
|
|
101
104
|
leaf.set(httpMethod, versions);
|
|
102
105
|
}
|
|
103
106
|
if (versions.has(versionKey)) {
|
|
107
|
+
const existing = versions.get(versionKey)!;
|
|
108
|
+
const version = versionKey ? ` (version ${versionKey})` : "";
|
|
104
109
|
throw new HttpSetupError(
|
|
105
|
-
`Duplicate route: ${httpMethod}
|
|
110
|
+
`Duplicate route: ${httpMethod} ${entry.template}${version} is mapped to both ${existing.action.name} (${existing.template}) and ${entry.action.name}. `
|
|
111
|
+
+ "Routes that differ only in parameter names are the same route; change one of the paths.",
|
|
106
112
|
);
|
|
107
113
|
}
|
|
108
114
|
versions.set(versionKey, entry);
|
|
@@ -199,3 +205,10 @@ function constraintPriority(constraint: SegmentConstraint | undefined): number {
|
|
|
199
205
|
return 0;
|
|
200
206
|
}
|
|
201
207
|
}
|
|
208
|
+
|
|
209
|
+
/** `/tasks/:id(int)/files/*path` from parsed segments. */
|
|
210
|
+
function renderTemplate(segments: readonly TemplateSegment[]): string {
|
|
211
|
+
return `/${segments.map((segment) => segment.kind === "static" ? segment.value
|
|
212
|
+
: segment.kind === "param" ? `:${segment.name}${segment.constraint ? `(${segment.constraint.name})` : ""}`
|
|
213
|
+
: `*${segment.name}`).join("/")}`;
|
|
214
|
+
}
|
|
@@ -232,7 +232,7 @@ export class CodexAppServerClient implements CodexClient {
|
|
|
232
232
|
if (method === "account/updated") this.modelCache = undefined;
|
|
233
233
|
}, () => { this.clearLogin(); this.modelCache = undefined; });
|
|
234
234
|
const initialized = await client.request("initialize", {
|
|
235
|
-
clientInfo: { name: "bazis", title: "Bazis", version: "0.
|
|
235
|
+
clientInfo: { name: "bazis", title: "Bazis", version: "0.98.1" }, capabilities: { experimentalApi: true },
|
|
236
236
|
}, 15_000, signal);
|
|
237
237
|
if (resolve(text(initialized.codexHome, 4096)) !== this.paths.home) throw new CodexError("PROTOCOL_ERROR");
|
|
238
238
|
client.notify("initialized");
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { DI, Global, Module, collectModuleConfigs, createContainer, type BazisModule, type BazisModuleRef } from "../di";
|
|
1
|
+
import { DI, Global, Module, collectModuleConfigs, createContainer, type BazisModule, type BazisModuleRef, type ProviderDefinition } from "../di";
|
|
2
2
|
import { isBazisModuleClass } from "../di/module/Module";
|
|
3
3
|
import { ApplicationLifetime } from "./ApplicationLifetime";
|
|
4
4
|
import { Configuration, loadConfiguration } from "./config/Configuration";
|
|
@@ -44,6 +44,7 @@ export class KernelBuilder {
|
|
|
44
44
|
private signals: readonly NodeJS.Signals[] = DEFAULT_SIGNALS;
|
|
45
45
|
private unhandledErrorPolicy: UnhandledErrorPolicy = "shutdown";
|
|
46
46
|
private validateOnBuild = true;
|
|
47
|
+
private overrides: readonly ProviderDefinition[] = [];
|
|
47
48
|
private startupReport?: boolean;
|
|
48
49
|
private logger?: Logger;
|
|
49
50
|
|
|
@@ -100,6 +101,15 @@ export class KernelBuilder {
|
|
|
100
101
|
return this;
|
|
101
102
|
}
|
|
102
103
|
|
|
104
|
+
/**
|
|
105
|
+
* Replacements for registered providers, for tests (see `createContainer`
|
|
106
|
+
* `overrides`): registered after the whole graph and visible to every module.
|
|
107
|
+
*/
|
|
108
|
+
public useOverrides(overrides: readonly ProviderDefinition[]): this {
|
|
109
|
+
this.overrides = [...this.overrides, ...overrides];
|
|
110
|
+
return this;
|
|
111
|
+
}
|
|
112
|
+
|
|
103
113
|
/** Sources are merged in registration order: later sources override earlier ones. */
|
|
104
114
|
public addConfigSource(source: ConfigSource): this {
|
|
105
115
|
this.configSources.push(source);
|
|
@@ -152,7 +162,7 @@ export class KernelBuilder {
|
|
|
152
162
|
const kernelRootModule = BazisKernelModule;
|
|
153
163
|
|
|
154
164
|
const containerStartedAt = performance.now();
|
|
155
|
-
const container = createContainer(kernelRootModule, { validateOnBuild: this.validateOnBuild });
|
|
165
|
+
const container = createContainer(kernelRootModule, { validateOnBuild: this.validateOnBuild, overrides: this.overrides });
|
|
156
166
|
const containerMs = performance.now() - containerStartedAt;
|
|
157
167
|
|
|
158
168
|
const timings: KernelTimings = {
|
|
@@ -178,6 +178,7 @@ interface HttpBindingSpec {
|
|
|
178
178
|
type?: "int" | "number" | "bool" | "string";
|
|
179
179
|
optional?: boolean;
|
|
180
180
|
model?: string;
|
|
181
|
+
array?: boolean;
|
|
181
182
|
}
|
|
182
183
|
let httpBindings: Record<string, Record<string, HttpBindingSpec[]>> = {};
|
|
183
184
|
interface GrpcRequestBinding {
|
|
@@ -1030,6 +1031,16 @@ function readConstructorTypeNames(node: ts.ClassDeclaration): ConstructorDepende
|
|
|
1030
1031
|
}
|
|
1031
1032
|
|
|
1032
1033
|
/** Resolve imported type aliases/re-exports; runtime named binding stays module-scoped. */
|
|
1034
|
+
/** The element keyword of an array type: `string[]`, `Array<string>`, `readonly string[]` -> "string". */
|
|
1035
|
+
function arrayElementKeyword(node: ts.TypeNode): string | undefined {
|
|
1036
|
+
if (ts.isTypeOperatorNode(node) && node.operator === ts.SyntaxKind.ReadonlyKeyword) return arrayElementKeyword(node.type);
|
|
1037
|
+
if (ts.isArrayTypeNode(node)) return node.elementType.getText().trim();
|
|
1038
|
+
if (ts.isTypeReferenceNode(node) && ["Array", "ReadonlyArray"].includes(node.typeName.getText()) && node.typeArguments?.length === 1) {
|
|
1039
|
+
return node.typeArguments[0]!.getText().trim();
|
|
1040
|
+
}
|
|
1041
|
+
return undefined;
|
|
1042
|
+
}
|
|
1043
|
+
|
|
1033
1044
|
function getDependencyTypeName(node: ts.TypeNode): string | undefined {
|
|
1034
1045
|
if (!ts.isTypeReferenceNode(node)) return undefined;
|
|
1035
1046
|
const symbol = checker.getSymbolAtLocation(node.typeName);
|
|
@@ -1425,6 +1436,13 @@ function collectControllerBindings(node: ts.ClassDeclaration, controllerName: st
|
|
|
1425
1436
|
specs.push({ source: "route", name: paramName, type: type === "string" ? undefined : type, optional });
|
|
1426
1437
|
continue;
|
|
1427
1438
|
}
|
|
1439
|
+
// `tag: string[]`, `Array<number>`, `readonly boolean[]` -> every ?tag= value.
|
|
1440
|
+
const elementKeyword = typeNode ? arrayElementKeyword(typeNode) : undefined;
|
|
1441
|
+
const elementType = elementKeyword !== undefined ? PRIMITIVE_QUERY_TYPES[elementKeyword] : undefined;
|
|
1442
|
+
if (elementType !== undefined) {
|
|
1443
|
+
specs.push({ source: "query", name: paramName, type: elementType === "string" ? undefined : elementType, optional, array: true });
|
|
1444
|
+
continue;
|
|
1445
|
+
}
|
|
1428
1446
|
const primitiveType =
|
|
1429
1447
|
keywordType !== undefined ? PRIMITIVE_QUERY_TYPES[keywordType] : inferTypeFromInitializer(param.initializer);
|
|
1430
1448
|
if (primitiveType !== undefined) {
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { HOSTED_SERVICE, createContainer, type BazisModuleRef, type CreateContainerOptions, type DiContainer, type ProviderDefinition } from "../di";
|
|
2
|
+
import { loadBazisGeneratedRuntime } from "../generatedRuntime";
|
|
3
|
+
import { HttpServer, type HttpModuleOptions } from "../http";
|
|
4
|
+
import { KernelBuilder, type Kernel } from "../kernel";
|
|
5
|
+
import { composeApp } from "../app/runApp";
|
|
6
|
+
import type { RunAppOptions } from "../app";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* A container for module tests. Loads the project's generated code first
|
|
10
|
+
* (without it constructor dependencies are missing and a test passes for the
|
|
11
|
+
* wrong reason), then builds the graph with the replacements.
|
|
12
|
+
*
|
|
13
|
+
* ```ts
|
|
14
|
+
* const container = await createTestContainer(TaskModule, { overrides: [singleton(ITaskStore, FakeStore)] });
|
|
15
|
+
* const tasks = container.createScope().resolve(TaskService);
|
|
16
|
+
* ```
|
|
17
|
+
*/
|
|
18
|
+
export async function createTestContainer(root: BazisModuleRef, options: CreateContainerOptions = {}): Promise<DiContainer> {
|
|
19
|
+
await loadBazisGeneratedRuntime();
|
|
20
|
+
return createContainer(root, { validateOnBuild: true, ...options });
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface TestAppOptions extends Omit<RunAppOptions, "http"> {
|
|
24
|
+
/** HTTP options; the test app always listens on 127.0.0.1 and a free port. `false`: no HTTP server. */
|
|
25
|
+
readonly http?: Omit<HttpModuleOptions, "port" | "hostname"> | false;
|
|
26
|
+
/** Replacements for registered providers, see `createContainer` `overrides`. */
|
|
27
|
+
readonly overrides?: readonly ProviderDefinition[];
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface TestApp {
|
|
31
|
+
/** `http://127.0.0.1:<port>`; empty without an HTTP server. */
|
|
32
|
+
readonly url: string;
|
|
33
|
+
/** `fetch` relative to {@link url}: `app.fetch("/tasks/1")`. */
|
|
34
|
+
fetch(path: string, init?: RequestInit): Promise<Response>;
|
|
35
|
+
/** The application container: `app.container.createScope().resolve(TaskService)`. */
|
|
36
|
+
readonly container: DiContainer;
|
|
37
|
+
/** Graceful stop, like SIGTERM in a real run. */
|
|
38
|
+
stop(): Promise<void>;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Starts the application in this process the way `runApp` does, for tests:
|
|
43
|
+
* environment `test`, a free port, no signal handlers, no startup report.
|
|
44
|
+
* Configuration errors and failing startup throw instead of exiting the process.
|
|
45
|
+
*
|
|
46
|
+
* ```ts
|
|
47
|
+
* const app = await startTestApp(AppModule, { overrides: [singleton(ITaskStore, FakeStore)] });
|
|
48
|
+
* try {
|
|
49
|
+
* expect((await app.fetch("/tasks/1")).status).toBe(200);
|
|
50
|
+
* } finally {
|
|
51
|
+
* await app.stop();
|
|
52
|
+
* }
|
|
53
|
+
* ```
|
|
54
|
+
*/
|
|
55
|
+
export async function startTestApp(root: BazisModuleRef, options: TestAppOptions = {}): Promise<TestApp> {
|
|
56
|
+
await loadBazisGeneratedRuntime();
|
|
57
|
+
const { http, overrides, configure, kernel: kernelOptions, ...rest } = options;
|
|
58
|
+
const appRoot = composeApp(root, { ...rest, http: http === false ? undefined : { ...http, hostname: "127.0.0.1", port: 0 } });
|
|
59
|
+
const builder = new KernelBuilder(appRoot).useOptions({ startupReport: false, ...kernelOptions, environment: kernelOptions?.environment ?? "test", signals: [] });
|
|
60
|
+
configure?.(builder);
|
|
61
|
+
if (overrides !== undefined) builder.useOverrides(overrides);
|
|
62
|
+
const kernel: Kernel = await builder.build();
|
|
63
|
+
await kernel.start();
|
|
64
|
+
const server = kernel.container.resolveAll(HOSTED_SERVICE).find((service): service is HttpServer => service instanceof HttpServer);
|
|
65
|
+
const url = server ? `http://127.0.0.1:${server.port}` : "";
|
|
66
|
+
return {
|
|
67
|
+
url,
|
|
68
|
+
container: kernel.container,
|
|
69
|
+
fetch: (path, init) => {
|
|
70
|
+
if (!url) throw new Error("startTestApp was started with http: false.");
|
|
71
|
+
return fetch(`${url}${path}`, init);
|
|
72
|
+
},
|
|
73
|
+
stop: () => kernel.stop(),
|
|
74
|
+
};
|
|
75
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "bazis",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.98.1",
|
|
4
4
|
"description": "Modular backend framework for Bun in TypeScript: DI wired by codegen, HTTP, a PostgreSQL ORM, JWT, WebSocket, agents and a CLI.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Alexander Alenin",
|
|
@@ -41,6 +41,7 @@
|
|
|
41
41
|
"./core/infra": "./core/infra/index.ts",
|
|
42
42
|
"./core/kernel": "./core/kernel/index.ts",
|
|
43
43
|
"./core/orm": "./core/orm/index.ts",
|
|
44
|
+
"./core/testing": "./core/testing/index.ts",
|
|
44
45
|
"./core/websocket": "./core/websocket/index.ts",
|
|
45
46
|
"./library/boundary": "./library/boundary/index.ts",
|
|
46
47
|
"./library/http-client": "./library/http-client/index.ts",
|